- 실행 시작 대화를 탭별로 추적해 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 이벤트와 완료 결과를 저장하도록 정리함 - 탭 복귀 시 진행 중인 대화를 다시 로드하고 백그라운드 실행 저장이 현재 선택 대화 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)
874 lines
32 KiB
C#
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");
|
|
}
|
|
}
|