Files
AX-Copilot-Codex/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs
lacvet f173e2a63b 같은 탭 대화 전환 중에도 AX Agent 실행이 계속되도록 수정
- 실행 시작 대화를 탭별로 추적해 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 이벤트와 완료 결과를 저장하도록 정리함
- 탭 복귀 시 진행 중인 대화를 다시 로드하고 백그라운드 실행 저장이 현재 선택 대화 ID를 덮어쓰지 않도록 세션/저장 경로를 보강함
- ChatSessionStateService와 AxAgentExecutionEngine 회귀 테스트를 추가하고 README.md, docs/DEVELOPMENT.md 이력을 갱신함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_conversation_background_resume\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume\ (경고 0, 오류 0)
- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatSessionStateServiceTests|AxAgentExecutionEngineTests -p:OutputPath=bin\verify_conversation_background_resume_tests\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume_tests\ (통과 39)
2026-04-15 19:24:40 +09:00

874 lines
32 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 AppendExecutionEvent_WithExplicitConversation_DoesNotReplaceVisibleConversation_WhenRememberDisabled()
{
var session = new ChatSessionStateService();
var visibleConversation = new ChatConversation
{
Id = $"visible-{Guid.NewGuid():N}",
Tab = "Code",
Title = "visible",
Messages = [new ChatMessage { Role = "user", Content = "keep me selected" }]
};
var runningConversation = new ChatConversation
{
Id = $"running-{Guid.NewGuid():N}",
Tab = "Code",
Title = "running",
Messages = [new ChatMessage { Role = "user", Content = "background run" }]
};
session.SetCurrentConversation("Code", visibleConversation);
session.RememberConversation("Code", visibleConversation.Id);
session.AppendExecutionEvent(
"Code",
runningConversation,
new AgentEvent
{
RunId = "run-background",
Type = AgentEventType.ToolResult,
ToolName = "file_read",
Summary = "background step",
Timestamp = DateTime.Now,
Success = true,
},
rememberConversation: false);
session.CurrentConversation.Should().BeSameAs(visibleConversation);
session.GetConversationId("Code").Should().Be(visibleConversation.Id);
runningConversation.ExecutionEvents.Should().ContainSingle();
runningConversation.ExecutionEvents[0].RunId.Should().Be("run-background");
}
[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(@"E:\workspace");
session.CurrentConversation.Should().BeSameAs(conversation);
}
[Fact]
public void CreateFreshConversation_PrefersTabSpecificWorkFolderOutsideChatTab()
{
var session = new ChatSessionStateService();
var settings = new SettingsService();
settings.Settings.Llm.WorkFolder = @"E:\global";
settings.Settings.Llm.CodeWorkFolder = @"E:\code";
var conversation = session.CreateFreshConversation("Code", settings);
conversation.WorkFolder.Should().Be(@"E:\code");
}
[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");
}
}