목적: - 긴 세션, 분기, 재시작 이후에도 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)
819 lines
30 KiB
C#
819 lines
30 KiB
C#
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Services.Agent;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace AxCopilot.Tests.Services;
|
|
|
|
public class ChatSessionStateServiceTests
|
|
{
|
|
[Fact]
|
|
public void AppendExecutionEvent_CreatesConversationAndTrimsToLatest400()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
for (var i = 0; i < 405; i++)
|
|
{
|
|
session.AppendExecutionEvent("Code", new AgentEvent
|
|
{
|
|
RunId = $"run-{i}",
|
|
Type = AgentEventType.ToolResult,
|
|
ToolName = "file_read",
|
|
Summary = $"event-{i}",
|
|
Timestamp = DateTime.Now.AddSeconds(i),
|
|
Success = true,
|
|
});
|
|
}
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Tab.Should().Be("Code");
|
|
session.CurrentConversation.ExecutionEvents.Should().HaveCount(400);
|
|
session.CurrentConversation.ExecutionEvents[0].RunId.Should().Be("run-5");
|
|
session.CurrentConversation.ExecutionEvents[^1].RunId.Should().Be("run-404");
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendExecutionEvent_MergesNearDuplicateEvents()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var now = DateTime.Now;
|
|
|
|
session.AppendExecutionEvent("Code", new AgentEvent
|
|
{
|
|
RunId = "run-dup",
|
|
Type = AgentEventType.ToolResult,
|
|
ToolName = "file_read",
|
|
Summary = "same summary",
|
|
Timestamp = now,
|
|
Success = true,
|
|
StepCurrent = 1,
|
|
StepTotal = 3,
|
|
InputTokens = 12,
|
|
OutputTokens = 8,
|
|
ElapsedMs = 20,
|
|
});
|
|
session.AppendExecutionEvent("Code", new AgentEvent
|
|
{
|
|
RunId = "run-dup",
|
|
Type = AgentEventType.ToolResult,
|
|
ToolName = "file_read",
|
|
Summary = "same summary",
|
|
Timestamp = now.AddSeconds(1),
|
|
Success = true,
|
|
StepCurrent = 2,
|
|
StepTotal = 3,
|
|
InputTokens = 18,
|
|
OutputTokens = 11,
|
|
ElapsedMs = 40,
|
|
});
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.ExecutionEvents.Should().HaveCount(1);
|
|
session.CurrentConversation.ExecutionEvents[0].StepCurrent.Should().Be(2);
|
|
session.CurrentConversation.ExecutionEvents[0].ElapsedMs.Should().Be(40);
|
|
session.CurrentConversation.ExecutionEvents[0].InputTokens.Should().Be(18);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendAgentRun_KeepsLatestTwelveRuns()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
for (var i = 0; i < 15; i++)
|
|
{
|
|
session.AppendAgentRun("Cowork", new AgentEvent
|
|
{
|
|
RunId = $"run-{i}",
|
|
Type = AgentEventType.Complete,
|
|
Summary = $"summary-{i}",
|
|
Timestamp = DateTime.Now.AddMinutes(-i),
|
|
Iteration = i,
|
|
}, "completed", $"done-{i}");
|
|
}
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.AgentRunHistory.Should().HaveCount(12);
|
|
session.CurrentConversation.AgentRunHistory[0].RunId.Should().Be("run-14");
|
|
session.CurrentConversation.AgentRunHistory[^1].RunId.Should().Be("run-3");
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendAgentRun_UpsertsByRunIdKeepingLatestState()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var baseTime = DateTime.Now;
|
|
|
|
session.AppendAgentRun("Code", new AgentEvent
|
|
{
|
|
RunId = "run-dup",
|
|
Type = AgentEventType.Thinking,
|
|
Summary = "running",
|
|
Timestamp = baseTime,
|
|
Iteration = 1,
|
|
}, "running", "running");
|
|
|
|
session.AppendAgentRun("Code", new AgentEvent
|
|
{
|
|
RunId = "run-dup",
|
|
Type = AgentEventType.Complete,
|
|
Summary = "completed",
|
|
Timestamp = baseTime.AddSeconds(5),
|
|
Iteration = 2,
|
|
}, "completed", "completed");
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.AgentRunHistory.Should().HaveCount(1);
|
|
session.CurrentConversation.AgentRunHistory[0].RunId.Should().Be("run-dup");
|
|
session.CurrentConversation.AgentRunHistory[0].Status.Should().Be("completed");
|
|
session.CurrentConversation.AgentRunHistory[0].LastIteration.Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnqueueDraft_AndToggleExecutionHistory_UpdateConversationState()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
var item = session.EnqueueDraft("Chat", " follow up draft ", "now");
|
|
var visible = session.ToggleExecutionHistory("Chat");
|
|
|
|
item.Should().NotBeNull();
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.DraftQueueItems.Should().ContainSingle();
|
|
session.CurrentConversation.DraftQueueItems[0].Text.Should().Be("follow up draft");
|
|
session.CurrentConversation.DraftQueueItems[0].Priority.Should().Be("now");
|
|
visible.Should().BeFalse();
|
|
session.CurrentConversation.ShowExecutionHistory.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void GetDraftQueueItems_ReturnsSnapshotOfCurrentConversationQueue()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
session.EnqueueDraft("Chat", "first draft", "next");
|
|
session.EnqueueDraft("Chat", "second draft", "later");
|
|
|
|
var items = session.GetDraftQueueItems("Chat");
|
|
|
|
items.Should().HaveCount(2);
|
|
items[0].Text.Should().Be("first draft");
|
|
items[1].Priority.Should().Be("later");
|
|
}
|
|
|
|
[Fact]
|
|
public void ScheduleDraftRetry_RequeuesBeforeMaxAttempts()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var item = session.EnqueueDraft("Chat", "retry me", "next");
|
|
session.MarkDraftRunning("Chat", item!.Id);
|
|
|
|
var scheduled = session.ScheduleDraftRetry("Chat", item.Id, "temporary", maxAutoRetries: 3);
|
|
|
|
scheduled.Should().BeTrue();
|
|
session.CurrentConversation!.DraftQueueItems[0].State.Should().Be("queued");
|
|
session.CurrentConversation!.DraftQueueItems[0].NextRetryAt.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveConversationListPreferences_PersistsFilterFlags()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.SaveConversationListPreferences("Chat", failedOnly: true, runningOnly: true, sortByRecent: true);
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.ConversationFailedOnlyFilter.Should().BeTrue();
|
|
session.CurrentConversation.ConversationRunningOnlyFilter.Should().BeTrue();
|
|
session.CurrentConversation.ConversationSortMode.Should().Be("recent");
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearCurrentConversation_RemovesRememberedConversationIdForTab()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.RememberConversation("Code", "conv-1");
|
|
session.ClearCurrentConversation("Code");
|
|
|
|
session.GetConversationId("Code").Should().BeNull();
|
|
session.CurrentConversation.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyExecutionHistoryExists()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var conv = session.EnsureCurrentConversation("Code");
|
|
conv.Messages.Clear();
|
|
conv.ExecutionEvents = new List<ChatExecutionEvent>
|
|
{
|
|
new()
|
|
{
|
|
RunId = "run-1",
|
|
Type = "ToolResult",
|
|
ToolName = "file_read",
|
|
Summary = "read",
|
|
Timestamp = DateTime.Now
|
|
}
|
|
};
|
|
conv.AgentRunHistory.Clear();
|
|
conv.DraftQueueItems.Clear();
|
|
|
|
session.SaveCurrentConversation(storage, "Code");
|
|
|
|
session.GetConversationId("Code").Should().Be(conv.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCurrentConversation_DoesNotPersistEmptyFreshConversation()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var conv = session.EnsureCurrentConversation("Cowork");
|
|
conv.Messages.Clear();
|
|
conv.ExecutionEvents.Clear();
|
|
conv.AgentRunHistory.Clear();
|
|
conv.DraftQueueItems.Clear();
|
|
conv.Preview = "";
|
|
conv.WorkFolder = "";
|
|
conv.SystemCommand = "";
|
|
conv.Permission = null;
|
|
conv.DataUsage = null;
|
|
conv.OutputFormat = null;
|
|
conv.Mood = null;
|
|
conv.Category = ChatCategory.General;
|
|
conv.Title = "새 대화";
|
|
|
|
session.SaveCurrentConversation(storage, "Cowork");
|
|
|
|
session.GetConversationId("Cowork").Should().BeNull();
|
|
storage.Load(conv.Id).Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyAgentRunHistoryExists()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var conv = session.EnsureCurrentConversation("Code");
|
|
conv.Messages.Clear();
|
|
conv.ExecutionEvents.Clear();
|
|
conv.AgentRunHistory = new List<ChatAgentRunRecord>
|
|
{
|
|
new()
|
|
{
|
|
RunId = "run-2",
|
|
Status = "completed",
|
|
Summary = "done",
|
|
UpdatedAt = DateTime.Now
|
|
}
|
|
};
|
|
conv.DraftQueueItems.Clear();
|
|
|
|
session.SaveCurrentConversation(storage, "Code");
|
|
|
|
session.GetConversationId("Code").Should().Be(conv.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyDraftQueueExists()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var conv = session.EnsureCurrentConversation("Code");
|
|
conv.Messages.Clear();
|
|
conv.ExecutionEvents.Clear();
|
|
conv.AgentRunHistory.Clear();
|
|
conv.DraftQueueItems = new List<DraftQueueItem>
|
|
{
|
|
new()
|
|
{
|
|
Id = "draft-1",
|
|
Text = "todo",
|
|
Priority = "next",
|
|
State = "queued",
|
|
CreatedAt = DateTime.Now
|
|
}
|
|
};
|
|
|
|
session.SaveCurrentConversation(storage, "Code");
|
|
|
|
session.GetConversationId("Code").Should().Be(conv.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveCurrentConversation_UsesConversationTabToAvoidCrossTabContamination()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var conv = new ChatConversation
|
|
{
|
|
Tab = "Code",
|
|
Messages = [new ChatMessage { Role = "user", Content = "code message" }]
|
|
};
|
|
session.CurrentConversation = conv;
|
|
|
|
session.SaveCurrentConversation(storage, "Chat");
|
|
|
|
session.GetConversationId("Code").Should().Be(conv.Id);
|
|
session.GetConversationId("Chat").Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void SetCurrentConversation_UpdatesCurrentConversationAndRememberedId()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var conversation = new ChatConversation { Id = "conv-42", Tab = "", Title = "test title" };
|
|
|
|
var current = session.SetCurrentConversation("Cowork", conversation);
|
|
|
|
current.Tab.Should().Be("Cowork");
|
|
session.CurrentConversation.Should().BeSameAs(conversation);
|
|
session.GetConversationId("Cowork").Should().Be("conv-42");
|
|
}
|
|
|
|
[Fact]
|
|
public void SetCurrentConversation_ForcesConversationTabToRequestedTab()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var conversation = new ChatConversation { Id = "conv-99", Tab = "Chat", Title = "wrong tab" };
|
|
|
|
var current = session.SetCurrentConversation("Code", conversation);
|
|
|
|
current.Tab.Should().Be("Code");
|
|
session.GetConversationId("Code").Should().Be("conv-99");
|
|
}
|
|
|
|
[Fact]
|
|
public void EnsureCurrentConversation_WhenTabDiffers_CreatesIsolatedConversation()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
session.CurrentConversation = new ChatConversation
|
|
{
|
|
Tab = "Chat",
|
|
Messages = [new ChatMessage { Role = "user", Content = "chat message" }]
|
|
};
|
|
|
|
var codeConversation = session.EnsureCurrentConversation("Code");
|
|
|
|
codeConversation.Tab.Should().Be("Code");
|
|
codeConversation.Messages.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadOrCreateConversation_WhenRememberedIdPointsToDifferentTab_CreatesFreshConversation()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var settings = new SettingsService();
|
|
|
|
var chatConversation = new ChatConversation
|
|
{
|
|
Id = "conv-chat-tab",
|
|
Tab = "Chat",
|
|
Title = "chat only"
|
|
};
|
|
storage.Save(chatConversation);
|
|
session.RememberConversation("Code", chatConversation.Id);
|
|
|
|
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
|
|
|
loaded.Tab.Should().Be("Code");
|
|
loaded.Id.Should().NotBe(chatConversation.Id);
|
|
session.GetConversationId("Code").Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Suite", "ReplayStability")]
|
|
public void LoadOrCreateConversation_NormalizesHistoryOrderAndCompactsSize()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var settings = new SettingsService();
|
|
var baseTime = new DateTime(2026, 4, 3, 1, 0, 0, DateTimeKind.Local);
|
|
|
|
var conversation = new ChatConversation
|
|
{
|
|
Id = $"conv-history-normalize-{Guid.NewGuid():N}",
|
|
Tab = "Code",
|
|
Title = "history normalize",
|
|
};
|
|
|
|
for (var i = 0; i < 420; i++)
|
|
{
|
|
conversation.ExecutionEvents.Add(new ChatExecutionEvent
|
|
{
|
|
RunId = $"run-{i % 20}",
|
|
Type = "ToolResult",
|
|
ToolName = "file_read",
|
|
Summary = $"event-{i}",
|
|
Timestamp = baseTime.AddSeconds(420 - i), // 역순 저장
|
|
});
|
|
}
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
conversation.AgentRunHistory.Add(new ChatAgentRunRecord
|
|
{
|
|
RunId = $"run-{i}",
|
|
Status = i % 2 == 0 ? "completed" : "failed",
|
|
Summary = $"summary-{i}",
|
|
UpdatedAt = baseTime.AddMinutes(i - 20), // 오래된 순 저장
|
|
StartedAt = baseTime.AddMinutes(i - 21),
|
|
});
|
|
}
|
|
|
|
storage.Save(conversation);
|
|
session.RememberConversation("Code", conversation.Id);
|
|
|
|
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
|
|
|
loaded.ExecutionEvents.Should().HaveCount(400);
|
|
loaded.ExecutionEvents.First().Timestamp.Should().BeBefore(loaded.ExecutionEvents.Last().Timestamp);
|
|
loaded.AgentRunHistory.Should().HaveCount(12);
|
|
loaded.AgentRunHistory.First().RunId.Should().Be("run-19");
|
|
loaded.AgentRunHistory.Last().RunId.Should().Be("run-8");
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Suite", "ReplayStability")]
|
|
public void LoadOrCreateConversation_NormalizesAgentRunDuplicatesByRunId()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var storage = new ChatStorageService();
|
|
var settings = new SettingsService();
|
|
var baseTime = new DateTime(2026, 4, 3, 1, 0, 0, DateTimeKind.Local);
|
|
|
|
var conversation = new ChatConversation
|
|
{
|
|
Id = $"conv-run-dedupe-{Guid.NewGuid():N}",
|
|
Tab = "Code",
|
|
Title = "run dedupe",
|
|
AgentRunHistory =
|
|
[
|
|
new ChatAgentRunRecord { RunId = "run-a", Status = "running", Summary = "old", UpdatedAt = baseTime.AddMinutes(-2), StartedAt = baseTime.AddMinutes(-3), LastIteration = 1 },
|
|
new ChatAgentRunRecord { RunId = "run-a", Status = "completed", Summary = "new", UpdatedAt = baseTime.AddMinutes(-1), StartedAt = baseTime.AddMinutes(-3), LastIteration = 2 },
|
|
new ChatAgentRunRecord { RunId = "run-b", Status = "failed", Summary = "other", UpdatedAt = baseTime, StartedAt = baseTime.AddMinutes(-1), LastIteration = 1 },
|
|
]
|
|
};
|
|
|
|
storage.Save(conversation);
|
|
session.RememberConversation("Code", conversation.Id);
|
|
|
|
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
|
|
|
loaded.AgentRunHistory.Should().HaveCount(2);
|
|
loaded.AgentRunHistory[0].RunId.Should().Be("run-b");
|
|
loaded.AgentRunHistory[1].RunId.Should().Be("run-a");
|
|
loaded.AgentRunHistory[1].Status.Should().Be("completed");
|
|
loaded.AgentRunHistory[1].Summary.Should().Be("new");
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_NormalizesLegacyAndCaseInsensitiveTabKeys()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var settings = new SettingsService();
|
|
settings.Settings.Llm.LastActiveTab = "code";
|
|
settings.Settings.Llm.LastConversationIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["coworkcode"] = "conv-cowork-legacy",
|
|
["code"] = "conv-code-lower",
|
|
["chat"] = "conv-chat-lower",
|
|
};
|
|
|
|
session.Load(settings);
|
|
|
|
session.ActiveTab.Should().Be("Code");
|
|
session.GetConversationId("Cowork").Should().Be("conv-cowork-legacy");
|
|
session.GetConversationId("Code").Should().Be("conv-code-lower");
|
|
session.GetConversationId("Chat").Should().Be("conv-chat-lower");
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendMessage_FirstUserMessageUpdatesConversationTitle()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.AppendMessage("Chat", new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "first user request"
|
|
}, useForTitle: true);
|
|
session.AppendMessage("Chat", new ChatMessage
|
|
{
|
|
Role = "assistant",
|
|
Content = "answer"
|
|
});
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Messages.Should().HaveCount(2);
|
|
session.CurrentConversation.Title.Should().Be("first user request");
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendMessage_RemembersConversationIdForActiveTab()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.AppendMessage("Code", new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = "run build"
|
|
}, useForTitle: true);
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Tab.Should().Be("Code");
|
|
session.GetConversationId("Code").Should().Be(session.CurrentConversation.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateConversationMetadata_UpdatesCurrentConversationFields()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
session.EnsureCurrentConversation("Code");
|
|
|
|
session.UpdateConversationMetadata("Code", conv =>
|
|
{
|
|
conv.Title = "new title";
|
|
conv.Category = ChatCategory.Product;
|
|
conv.SystemCommand = "system prompt";
|
|
});
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Title.Should().Be("new title");
|
|
session.CurrentConversation.Category.Should().Be(ChatCategory.Product);
|
|
session.CurrentConversation.SystemCommand.Should().Be("system prompt");
|
|
}
|
|
|
|
[Fact]
|
|
public void SaveConversationSettings_UpdatesConversationScopedPreferences()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.SaveConversationSettings("Cowork", "Ask", "active", "markdown", "modern");
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Permission.Should().Be("Ask");
|
|
session.CurrentConversation.DataUsage.Should().Be("active");
|
|
session.CurrentConversation.OutputFormat.Should().Be("markdown");
|
|
session.CurrentConversation.Mood.Should().Be("modern");
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveLastAssistantMessage_RemovesOnlyAssistantTail()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u1" });
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a1" });
|
|
|
|
var removed = session.RemoveLastAssistantMessage("Chat");
|
|
|
|
removed.Should().BeTrue();
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Messages.Should().ContainSingle();
|
|
session.CurrentConversation.Messages[0].Role.Should().Be("user");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateUserMessageAndTrim_RewritesTailFromTargetIndex()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u1" });
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a1" });
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u2" });
|
|
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a2" });
|
|
|
|
var updated = session.UpdateUserMessageAndTrim("Chat", 2, "u2-edited");
|
|
|
|
updated.Should().BeTrue();
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Messages.Should().HaveCount(3);
|
|
session.CurrentConversation.Messages[2].Content.Should().Be("u2-edited");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateMessageFeedback_UpdatesStoredMessageFeedback()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var message = new ChatMessage { Role = "assistant", Content = "answer" };
|
|
session.AppendMessage("Chat", message);
|
|
|
|
var updated = session.UpdateMessageFeedback("Chat", message, "like");
|
|
|
|
updated.Should().BeTrue();
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.Messages[0].Feedback.Should().Be("like");
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateFreshConversation_AppliesDefaultWorkFolderOutsideChatTab()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var settings = new SettingsService();
|
|
settings.Settings.Llm.WorkFolder = @"E:\workspace";
|
|
|
|
var conversation = session.CreateFreshConversation("Code", settings);
|
|
|
|
conversation.Tab.Should().Be("Code");
|
|
conversation.WorkFolder.Should().Be("");
|
|
session.CurrentConversation.Should().BeSameAs(conversation);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateBranchConversation_ClonesConversationContextUpToBranchPoint()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var source = new ChatConversation
|
|
{
|
|
Id = "source-1",
|
|
Title = "Main",
|
|
Tab = "Code",
|
|
Category = ChatCategory.Product,
|
|
WorkFolder = @"E:\workspace",
|
|
SystemCommand = "system",
|
|
ConversationFailedOnlyFilter = true,
|
|
ConversationRunningOnlyFilter = true,
|
|
ConversationSortMode = "recent",
|
|
AgentRunHistory =
|
|
[
|
|
new ChatAgentRunRecord { RunId = "run-1", Status = "completed", Summary = "done", LastIteration = 2 }
|
|
],
|
|
Messages =
|
|
[
|
|
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) }
|
|
]
|
|
};
|
|
|
|
var branch = session.CreateBranchConversation(source, 1, 2, "follow-up", "context message", "run-ctx");
|
|
|
|
branch.ParentId.Should().Be("source-1");
|
|
branch.Tab.Should().Be("Code");
|
|
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()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var first = session.EnqueueDraft("Chat", "first", "next");
|
|
var second = session.EnqueueDraft("Chat", "second", "now");
|
|
|
|
var next = session.GetNextQueuedDraft("Chat");
|
|
|
|
next.Should().NotBeNull();
|
|
next!.Id.Should().Be(second!.Id);
|
|
session.MarkDraftRunning("Chat", second.Id).Should().BeTrue();
|
|
session.MarkDraftFailed("Chat", second.Id, "error").Should().BeTrue();
|
|
session.MarkDraftCompleted("Chat", first!.Id).Should().BeTrue();
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.DraftQueueItems.Should().Contain(x => x.Id == second.Id && x.State == "failed" && x.LastError == "error");
|
|
session.CurrentConversation.DraftQueueItems.Should().Contain(x => x.Id == first.Id && x.State == "completed");
|
|
}
|
|
|
|
[Fact]
|
|
public void DraftStateHelpers_ResetAndRemoveDraft()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var item = session.EnqueueDraft("Chat", "queued item", "next");
|
|
|
|
session.MarkDraftRunning("Chat", item!.Id).Should().BeTrue();
|
|
session.MarkDraftFailed("Chat", item.Id, "boom").Should().BeTrue();
|
|
session.ResetDraftToQueued("Chat", item.Id).Should().BeTrue();
|
|
session.RemoveDraft("Chat", item.Id).Should().BeTrue();
|
|
|
|
session.CurrentConversation.Should().NotBeNull();
|
|
session.CurrentConversation!.DraftQueueItems.Should().NotContain(x => x.Id == item.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetDraftQueueSummary_ReturnsConversationQueueSnapshot()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
var first = session.EnqueueDraft("Chat", "first", "next");
|
|
var second = session.EnqueueDraft("Chat", "second", "now");
|
|
|
|
session.MarkDraftRunning("Chat", second!.Id);
|
|
session.MarkDraftCompleted("Chat", second.Id);
|
|
|
|
var summary = session.GetDraftQueueSummary("Chat");
|
|
|
|
summary.TotalCount.Should().Be(2);
|
|
summary.QueuedCount.Should().Be(1);
|
|
summary.CompletedCount.Should().Be(1);
|
|
summary.NextItem.Should().NotBeNull();
|
|
summary.NextItem!.Id.Should().Be(first!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void RememberConversation_NormalizesCoworkAliasesToSingleBucket()
|
|
{
|
|
var session = new ChatSessionStateService();
|
|
|
|
session.RememberConversation("Cowork Code", "conv-1");
|
|
session.RememberConversation("cowork/code", "conv-2");
|
|
session.RememberConversation("코워크/코드", "conv-3");
|
|
|
|
session.GetConversationId("Cowork").Should().Be("conv-3");
|
|
session.GetConversationId("Code").Should().BeNull();
|
|
session.GetConversationId("Chat").Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadOrCreateConversation_RestoresRunHistoryAndExecutionEventsAfterRestart()
|
|
{
|
|
var storage = new ChatStorageService();
|
|
var settings = new SettingsService();
|
|
var sessionA = new ChatSessionStateService();
|
|
var conv = new ChatConversation
|
|
{
|
|
Id = $"conv-replay-{Guid.NewGuid():N}",
|
|
Tab = "Code",
|
|
Title = "restore test",
|
|
Messages = [new ChatMessage { Role = "user", Content = "build it" }],
|
|
ExecutionEvents =
|
|
[
|
|
new ChatExecutionEvent
|
|
{
|
|
RunId = "run-restore-1",
|
|
Type = "ToolResult",
|
|
ToolName = "build_run",
|
|
Summary = "build ok",
|
|
Timestamp = DateTime.Now
|
|
}
|
|
],
|
|
AgentRunHistory =
|
|
[
|
|
new ChatAgentRunRecord
|
|
{
|
|
RunId = "run-restore-1",
|
|
Status = "completed",
|
|
Summary = "done",
|
|
UpdatedAt = DateTime.Now
|
|
}
|
|
]
|
|
};
|
|
|
|
sessionA.SetCurrentConversation("Code", conv, storage);
|
|
sessionA.SaveCurrentConversation(storage, "Code");
|
|
sessionA.Save(settings);
|
|
|
|
var sessionB = new ChatSessionStateService();
|
|
sessionB.Load(settings);
|
|
var restored = sessionB.LoadOrCreateConversation("Code", storage, settings);
|
|
|
|
restored.Id.Should().Be(conv.Id);
|
|
restored.Tab.Should().Be("Code");
|
|
restored.ExecutionEvents.Should().ContainSingle();
|
|
restored.AgentRunHistory.Should().ContainSingle();
|
|
restored.AgentRunHistory[0].RunId.Should().Be("run-restore-1");
|
|
}
|
|
}
|