Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions PolyPilot.IntegrationTests/ResetConversationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using PolyPilot.IntegrationTests.Fixtures;

namespace PolyPilot.IntegrationTests;

/// <summary>
/// Integration tests for the "Reset Conversation" context menu feature.
/// Verifies the menu item appears and triggers history clear via the live Blazor UI.
/// </summary>
[Collection("PolyPilot")]
[Trait("Category", "ResetConversation")]
public class ResetConversationTests : IntegrationTestBase
{
public ResetConversationTests(AppFixture app, ITestOutputHelper output)
: base(app, output) { }

[Fact]
public async Task ContextMenu_ContainsResetConversation()
{
await WaitForCdpReadyAsync();

// Right-click (open context menu) on the first session item
var menuItemExists = await CdpEvalAsync(@"
const item = document.querySelector('.session-item');
if (!item) return 'no session item';
// Trigger context menu
item.dispatchEvent(new PointerEvent('contextmenu', { bubbles: true }));
'triggered'
");
Output.WriteLine($"Context menu trigger: {menuItemExists}");

if (menuItemExists == "no session item")
{
Assert.Skip("No session items found β€” app may not have sessions in CI");
return;
}

// Wait for menu to open
await Task.Delay(1000);

// Check for the Reset Conversation menu item
var hasResetItem = await CdpEvalAsync(@"
const items = [...document.querySelectorAll('.menu-item')];
const resetItem = items.find(el => el.textContent?.includes('Reset Conversation'));
resetItem ? 'found' : 'not found'
");
Output.WriteLine($"Reset Conversation menu item: {hasResetItem}");

// Close the menu
var overlay = await ClickAsync(".menu-overlay");
Output.WriteLine($"Close menu: {overlay}");

await ScreenshotAsync("context-menu-reset-conversation");

Assert.Equal("found", hasResetItem);
}
}
62 changes: 62 additions & 0 deletions PolyPilot.Tests/SessionDisposalResilienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,68 @@ public void ClearHistory_NonExistentSession_DoesNotThrow()
svc.ClearHistory("ghost");
}

// --- ClearHistoryAsync (server-side truncation) ---

[Fact]
public async Task ClearHistoryAsync_ResetsMessageCount()
{
var svc = CreateService();
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });

var session = await svc.CreateSessionAsync("clear-async");
await svc.SendPromptAsync("clear-async", "msg1");
await svc.SendPromptAsync("clear-async", "msg2");
Assert.Equal(2, session.History.Count);
Assert.Equal(2, session.MessageCount);

var truncated = await svc.ClearHistoryAsync("clear-async");
Assert.False(truncated); // demo mode = no server truncation
Assert.Empty(session.History);
Assert.Equal(0, session.MessageCount);
}

[Fact]
public async Task ClearHistoryAsync_NonExistentSession_DoesNotThrow()
{
var svc = CreateService();
// Should not throw even for sessions that don't exist
var result = await svc.ClearHistoryAsync("ghost-async");
Assert.False(result);
}

[Fact]
public async Task ClearHistoryAsync_DemoMode_ClearsLocalOnly()
{
// In demo mode there's no SDK session, so server-side truncation
// is skipped but local history is still cleared.
var svc = CreateService();
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });

var session = await svc.CreateSessionAsync("demo-clear");
await svc.SendPromptAsync("demo-clear", "hello");
Assert.Single(session.History);

var truncated = await svc.ClearHistoryAsync("demo-clear");
Assert.False(truncated); // demo mode = no server truncation
Assert.Empty(session.History);
Assert.Equal(0, session.MessageCount);
}

[Fact]
public async Task ClearHistoryAsync_ClearsChatDatabase()
{
var svc = CreateService();
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });

var session = await svc.CreateSessionAsync("db-clear");
await svc.SendPromptAsync("db-clear", "hello");

await svc.ClearHistoryAsync("db-clear");

// Verify that ClearSessionAsync was called on the chat database
Assert.Contains(_chatDb.ClearedSessions, id => id == session.SessionId);
}

// --- DisposeAsync edge cases ---

[Fact]
Expand Down
7 changes: 7 additions & 0 deletions PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public Task UpdateReasoningContentAsync(string sessionId, string reasoningId, st

public Task<List<ChatMessage>> GetAllMessagesAsync(string sessionId)
=> Task.FromResult(new List<ChatMessage>());

public List<string> ClearedSessions { get; } = new();
public Task ClearSessionAsync(string sessionId)
{
ClearedSessions.Add(sessionId);
return Task.CompletedTask;
}
}

#pragma warning disable CS0067 // Events declared but never used in stubs
Expand Down
7 changes: 7 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@
πŸ”„ Continue in new session
</button>
}
@if (!Session.IsProcessing)
{
<button class="menu-item" @onclick="async () => { await OnCloseMenu.InvokeAsync(); await OnClearHistory.InvokeAsync(); }">
🧹 Reset Conversation
</button>
}
<div class="menu-separator"></div>
@if (PlatformHelper.IsDesktop && !string.IsNullOrEmpty(Session.SessionId) && currentGroup?.IsCodespace != true)
{
Expand Down Expand Up @@ -344,6 +350,7 @@
[Parameter] public EventCallback OnFixWithCopilot { get; set; }
[Parameter] public EventCallback OnAnalyze { get; set; }
[Parameter] public EventCallback OnContinueInNewSession { get; set; }
[Parameter] public EventCallback OnClearHistory { get; set; }
[Parameter] public EventCallback OnToggleChildren { get; set; }
[Parameter] public EventCallback<MultiAgentMode> OnInlineModeChanged { get; set; }

Expand Down
36 changes: 32 additions & 4 deletions PolyPilot/Components/Layout/SessionSidebar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,7 @@ else
OnFixWithCopilot="() => OpenFixItForSession(orchSName)"
OnAnalyze="() => AnalyzeSessionEfficiency(orchSName)"
OnContinueInNewSession="() => ContinueInNewSession(orchSName)"
OnClearHistory="() => ClearSessionHistory(orchSName)"
ShowChildrenToggle="@(workerCount > 0)"
AreChildrenCollapsed="group.UnpinnedCollapsed"
ChildCount="@workerCount"
Expand Down Expand Up @@ -1357,7 +1358,8 @@ else
OnReportBug="() => OpenBugReportForSession(wSName)"
OnFixWithCopilot="() => OpenFixItForSession(wSName)"
OnAnalyze="() => AnalyzeSessionEfficiency(wSName)"
OnContinueInNewSession="() => ContinueInNewSession(wSName)" />
OnContinueInNewSession="() => ContinueInNewSession(wSName)"
OnClearHistory="() => ClearSessionHistory(wSName)" />
}
</div>
}
Expand Down Expand Up @@ -1394,7 +1396,8 @@ else
OnReportBug="() => OpenBugReportForSession(sName)"
OnFixWithCopilot="() => OpenFixItForSession(sName)"
OnAnalyze="() => AnalyzeSessionEfficiency(sName)"
OnContinueInNewSession="() => ContinueInNewSession(sName)" />
OnContinueInNewSession="() => ContinueInNewSession(sName)"
OnClearHistory="() => ClearSessionHistory(sName)" />
}
@if (groupModel.ShowUnpinnedToggle)
{
Expand Down Expand Up @@ -1433,7 +1436,8 @@ else
OnReportBug="() => OpenBugReportForSession(sName)"
OnFixWithCopilot="() => OpenFixItForSession(sName)"
OnAnalyze="() => AnalyzeSessionEfficiency(sName)"
OnContinueInNewSession="() => ContinueInNewSession(sName)" />
OnContinueInNewSession="() => ContinueInNewSession(sName)"
OnClearHistory="() => ClearSessionHistory(sName)" />
}
}
</div>
Expand Down Expand Up @@ -1624,7 +1628,8 @@ else
OnReportBug="() => OpenBugReportForSession(sName)"
OnFixWithCopilot="() => OpenFixItForSession(sName)"
OnAnalyze="() => AnalyzeSessionEfficiency(sName)"
OnContinueInNewSession="() => ContinueInNewSession(sName)" />
OnContinueInNewSession="() => ContinueInNewSession(sName)"
OnClearHistory="() => ClearSessionHistory(sName)" />
}
@if (!string.IsNullOrEmpty(_codespaceErrors.GetValueOrDefault(group.Id)))
{
Expand Down Expand Up @@ -3561,6 +3566,29 @@ else
}
}

private async Task ClearSessionHistory(string sessionName)
{
try
{
var truncated = await CopilotService.ClearHistoryAsync(sessionName);
var session = CopilotService.GetSession(sessionName);
if (session != null)
{
var msg = truncated
? "Conversation reset. Server-side history truncated."
: "Conversation reset (local only).";
session.History.Add(ChatMessage.SystemMessage(msg));
session.MessageCount = session.History.Count;
}
StateHasChanged();
}
catch (Exception ex)
{
footerStatus = $"βœ— Reset failed: {ex.Message}";
StateHasChanged();
}
}

private void CloseFooterPanel()
{
showBugReport = false;
Expand Down
12 changes: 10 additions & 2 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -1890,8 +1890,16 @@
break;

case "clear":
CopilotService.ClearHistory(sessionName);
session.History.Add(ChatMessage.SystemMessage("Chat history cleared."));
if (session.IsProcessing)
{
session.History.Add(ChatMessage.SystemMessage("Cannot reset while processing."));
break;
}
var clearTruncated = await CopilotService.ClearHistoryAsync(sessionName);
session.History.Add(ChatMessage.SystemMessage(clearTruncated
? "Chat history cleared. Server-side history truncated."
: "Chat history cleared (local only)."));
session.MessageCount = session.History.Count;
break;

case "agent":
Expand Down
36 changes: 36 additions & 0 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5229,6 +5229,42 @@ public void ClearHistory(string name)
}
}

/// <summary>
/// Clears session history both locally and on the server via HistoryApi.TruncateAsync.
/// Falls back to local-only clear if the SDK call fails or the session has no live connection.
/// </summary>
public async Task<bool> ClearHistoryAsync(string name)
{
if (!_sessions.TryGetValue(name, out var state))
return false;

bool serverTruncated = false;

// Attempt server-side truncation if we have a live SDK session
if (!IsDemoMode && !IsRemoteMode && state.Session is { } session)
{
try
{
await session.Rpc.History.TruncateAsync(state.Info.SessionId ?? "", CancellationToken.None);
serverTruncated = true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[ClearHistory] Server-side truncation failed for '{name}': {ex.Message}");
// Fall through to local clear β€” the user still gets a reset UI
}
}

// Clear local chat database so LoadBestHistoryAsync doesn't restore cleared messages
if (!string.IsNullOrEmpty(state.Info.SessionId))
await _chatDb.ClearSessionAsync(state.Info.SessionId);

state.Info.History.Clear();
state.Info.MessageCount = 0;
OnStateChanged?.Invoke();
return serverTruncated;
}

public IEnumerable<AgentSessionInfo> GetAllSessions() => _sessions.Values.Select(s => s.Info).Where(s => !s.IsHidden);
public IEnumerable<string> GetAllSessionNames() => _sessions.Keys;

Expand Down
1 change: 1 addition & 0 deletions PolyPilot/Services/IChatDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public interface IChatDatabase
Task<List<ChatMessage>> GetAllMessagesAsync(string sessionId);
Task UpdateToolCompleteAsync(string sessionId, string toolCallId, string result, bool success);
Task UpdateReasoningContentAsync(string sessionId, string reasoningId, string content, bool isComplete);
Task ClearSessionAsync(string sessionId);
}