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
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<Compile Include="../PolyPilot/Models/PromptLibrary.cs" Link="Shared/PromptLibrary.cs" />
<Compile Include="../PolyPilot/Models/UsageStatistics.cs" Link="Shared/UsageStatistics.cs" />
<Compile Include="../PolyPilot/Services/PromptLibraryService.cs" Link="Shared/PromptLibraryService.cs" />
<Compile Include="../PolyPilot/Services/SessionAnalyzerService.cs" Link="Shared/SessionAnalyzerService.cs" />
<Compile Include="../PolyPilot/Services/UsageStatsService.cs" Link="Shared/UsageStatsService.cs" />
<Compile Include="../PolyPilot/Services/NotificationMessageBuilder.cs" Link="Shared/NotificationMessageBuilder.cs" />
<Compile Include="../PolyPilot/Services/IChatDatabase.cs" Link="Shared/IChatDatabase.cs" />
Expand Down
295 changes: 295 additions & 0 deletions PolyPilot.Tests/SessionAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
using PolyPilot.Services;

namespace PolyPilot.Tests;

[Collection("BaseDir")]
public class SessionAnalyzerTests
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Test isolation: missing [Collection("BaseDir")] and no _polypilotDir restore (3/3 reviewers)

Three tests call SetBaseDirForTesting(tempDir) then delete tempDir, but never restore _polypilotDir to TestSetup.TestBaseDir. After these tests run, _polypilotDir points to a deleted directory, corrupting subsequent tests. Also, the class lacks [Collection("BaseDir")], enabling xUnit parallel execution races.

Fix:

  1. Add [Collection("BaseDir")] to the class
  2. Add SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); in each finally
  3. Register in TestSetup.Initialize()

{
[Fact]
public void CollectDiagnostics_IncludesServerHealth()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}");
Directory.CreateDirectory(tempDir);
try
{
SessionAnalyzerService.SetBaseDirForTesting(tempDir);

File.WriteAllText(
Path.Combine(tempDir, "event-diagnostics.log"),
"[SEND] 'TestSession' IsProcessing=true\n[COMPLETE] 'TestSession' done\n");

var copilotService = CreateService();
var serverManager = new TestServerManager { IsRunning = true, Pid = 12345, Port = 4321 };
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

var diagnostics = analyzer.CollectDiagnostics();

Assert.Contains("Event Diagnostics", diagnostics);
Assert.Contains("[SEND]", diagnostics);
Assert.Contains("[COMPLETE]", diagnostics);
Assert.Contains("Server running: True", diagnostics);
Assert.Contains("12345", diagnostics);
}
finally
{
SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir);
Directory.Delete(tempDir, recursive: true);
}
}

[Fact]
public void CollectDiagnostics_IncludesCrashLog_WhenPresent()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}");
Directory.CreateDirectory(tempDir);
try
{
SessionAnalyzerService.SetBaseDirForTesting(tempDir);

File.WriteAllText(
Path.Combine(tempDir, "crash.log"),
"=== 2026-04-18 ===\nSystem.Exception: test crash\n");

var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

var diagnostics = analyzer.CollectDiagnostics();

Assert.Contains("Crash Log", diagnostics);
Assert.Contains("test crash", diagnostics);
}
finally
{
SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir);
Directory.Delete(tempDir, recursive: true);
}
}

[Fact]
public void CollectDiagnostics_HandlesEmptyLogs()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}");
Directory.CreateDirectory(tempDir);
try
{
SessionAnalyzerService.SetBaseDirForTesting(tempDir);

var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

var diagnostics = analyzer.CollectDiagnostics();

Assert.Contains("Active Session States", diagnostics);
Assert.Contains("Server Health", diagnostics);
}
finally
{
SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir);
Directory.Delete(tempDir, recursive: true);
}
}

[Fact]
public void BuildAnalysisPrompt_ContainsDiagnosticData()
{
var diagnostics = "## Test Data\nSome diagnostic info here";
var prompt = SessionAnalyzerService.BuildAnalysisPrompt(diagnostics);

Assert.Contains("PolyPilot Session Analyzer", prompt);
Assert.Contains("Stuck sessions", prompt);
Assert.Contains("Watchdog kills", prompt);
Assert.Contains("Test Data", prompt);
Assert.Contains("Some diagnostic info here", prompt);
}

[Fact]
public void BuildAnalysisPrompt_InstructsSingleBranchPrStrategy()
{
var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data");

// Must instruct reuse of a single branch and PR
Assert.Contains("fix/session-analyzer-findings", prompt);
Assert.Contains("Always reuse the SAME branch", prompt);
Assert.Contains("Never create a new branch per finding", prompt);
}

[Fact]
public void Constants_HaveReasonableDefaults()
{
Assert.Equal(10, SessionAnalyzerService.DefaultAnalysisIntervalMinutes);
Assert.Equal(1, SessionAnalyzerService.MinAnalysisIntervalMinutes);
Assert.Equal(1440, SessionAnalyzerService.MaxAnalysisIntervalMinutes);
Assert.Equal(200, SessionAnalyzerService.DiagnosticLogTailLines);
Assert.Equal(50, SessionAnalyzerService.CrashLogTailLines);
Assert.Equal("PolyPilot Monitor", SessionAnalyzerService.AnalyzerSessionName);
Assert.Equal(10 * 1024 * 1024, SessionAnalyzerService.MaxLogFileSizeBytes);
}

[Fact]
public void IsRunning_FalseBeforeStart()
{
var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

Assert.False(analyzer.IsRunning);
Assert.Null(analyzer.LastAnalysisAt);
Assert.Equal(0, analyzer.AnalysisCount);
}

[Fact]
public void Dispose_StopsAnalyzer()
{
var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

analyzer.Dispose();
analyzer.Dispose(); // double dispose is safe
Assert.False(analyzer.IsRunning);
}

[Fact]
public async Task DisposeAsync_StopsAnalyzer()
{
var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

await analyzer.DisposeAsync();
await analyzer.DisposeAsync(); // double dispose is safe
Assert.False(analyzer.IsRunning);
}

[Fact]
public async Task RunSingleAnalysis_ReturnsNull_WhenNoSessionCreated()
{
var copilotService = CreateService();
var serverManager = new TestServerManager();
var analyzer = new SessionAnalyzerService(copilotService, serverManager);

// _analyzerSessionName is null — no session was created
var result = await analyzer.RunSingleAnalysisAsync();
Assert.Null(result);
Assert.Equal(0, analyzer.AnalysisCount);
}

[Fact]
public void TailFile_CapsLargeFiles()
{
var tempFile = Path.GetTempFileName();
try
{
// Write a small file — TailFile should return last N lines
var lines = Enumerable.Range(1, 500).Select(i => $"line {i}").ToArray();
File.WriteAllLines(tempFile, lines);

var result = SessionAnalyzerService.TailFile(tempFile, 10);
Assert.Equal(10, result.Length);
Assert.Equal("line 491", result[0]);
Assert.Equal("line 500", result[9]);
}
finally
{
File.Delete(tempFile);
}
}

[Fact]
public void TailFile_HandlesSmallFile()
{
var tempFile = Path.GetTempFileName();
try
{
File.WriteAllLines(tempFile, new[] { "a", "b", "c" });
var result = SessionAnalyzerService.TailFile(tempFile, 10);
Assert.Equal(3, result.Length);
}
finally
{
File.Delete(tempFile);
}
}

[Fact]
public void TailFile_HandlesNonexistentFile()
{
var result = SessionAnalyzerService.TailFile("/nonexistent/path", 10);
Assert.Empty(result);
}

[Fact]
public void SessionAnalyzerIntervalMinutes_ClampsToMinimum()
{
var settings = new PolyPilot.Models.ConnectionSettings();

settings.SessionAnalyzerIntervalMinutes = 0;
Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes);

settings.SessionAnalyzerIntervalMinutes = -5;
Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes);

settings.SessionAnalyzerIntervalMinutes = 30;
Assert.Equal(30, settings.SessionAnalyzerIntervalMinutes);
}

[Fact]
public void SessionAnalyzerIntervalMinutes_ClampsToMaximum()
{
var settings = new PolyPilot.Models.ConnectionSettings();

settings.SessionAnalyzerIntervalMinutes = 2000;
Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes);

settings.SessionAnalyzerIntervalMinutes = int.MaxValue;
Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes);
}

[Fact]
public void BuildAnalysisPrompt_UsesAutopilotMode()
{
// The analyzer runs in autopilot so it can create/update PRs
var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data");

Assert.Contains("Write the fix, run tests, commit", prompt);
Assert.Contains("open one with a clear title", prompt);
}

private static string GetTempDir() => Path.GetTempPath();

private static CopilotService CreateService()
{
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
var serviceProvider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions
.BuildServiceProvider(services);
return new CopilotService(
new StubChatDatabase(),
new StubServerManager(),
new StubWsBridgeClient(),
new RepoManager(),
serviceProvider,
new StubDemoService());
}

private class TestServerManager : IServerManager
{
public bool IsRunning { get; set; }
public int? Pid { get; set; }
public int Port { get; set; } = 4321;
public string? Error { get; set; }

bool IServerManager.IsServerRunning => IsRunning;
int? IServerManager.ServerPid => Pid;
int IServerManager.ServerPort => Port;
string? IServerManager.LastError => Error;

public event Action? OnStatusChanged;

public bool CheckServerRunning(string host = "127.0.0.1", int? port = null) => IsRunning;
public Task<bool> StartServerAsync(int port, string? githubToken = null) => Task.FromResult(true);
public void StopServer() { }
public bool DetectExistingServer() => IsRunning;
}
}
1 change: 1 addition & 0 deletions PolyPilot.Tests/TestSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ internal static void Initialize()
FiestaService.SetStateFilePathForTesting(Path.Combine(TestBaseDir, "fiesta.json"));
ConnectionSettings.SetSettingsFilePathForTesting(Path.Combine(TestBaseDir, "settings.json"));
ScheduledTaskService.SetTasksFilePathForTesting(Path.Combine(TestBaseDir, "scheduled-tasks.json"));
SessionAnalyzerService.SetBaseDirForTesting(TestBaseDir);
}
}
47 changes: 47 additions & 0 deletions PolyPilot/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,30 @@
</div>
}

@if (PlatformHelper.IsDesktop)
{
<div class="settings-section">
<div class="notifications-toggle">
<label class="toggle-label">
<input type="checkbox" checked="@settings.EnableSessionAnalyzer"
@onchange="ToggleSessionAnalyzer" />
<span class="toggle-text">🔍 Session Analyzer</span>
</label>
<p class="toggle-hint">Background monitor that analyzes sessions for reliability issues and can auto-fix bugs via PR</p>
</div>
@if (settings.EnableSessionAnalyzer)
{
<div class="notifications-toggle" style="margin-left: 1.5em;">
<label class="toggle-label">
<span class="toggle-text">Analysis interval (minutes):</span>
<input type="number" min="1" max="1440" value="@settings.SessionAnalyzerIntervalMinutes"
@onchange="OnAnalyzerIntervalChanged" style="width: 5em; margin-left: 0.5em;" class="form-input" />
</label>
</div>
}
</div>
}

<div class="settings-section">
<div class="save-row">
<button class="save-btn" @onclick="SaveAndApply">
Expand Down Expand Up @@ -1398,6 +1422,29 @@
SaveSettingsQuietly();
}

private async Task ToggleSessionAnalyzer(ChangeEventArgs e)
{
settings.EnableSessionAnalyzer = e.Value is true;
settings.Save();
if (settings.EnableSessionAnalyzer)
{
CopilotService.StartSessionAnalyzerIfEnabled();
}
else
{
await CopilotService.StopSessionAnalyzerAsync();
}
}

private void OnAnalyzerIntervalChanged(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var val))
{
settings.SessionAnalyzerIntervalMinutes = val;
SaveSettingsQuietly();
}
}

private void ToggleAutoUpdate()
{
if (GitAutoUpdate.IsEnabled)
Expand Down
1 change: 1 addition & 0 deletions PolyPilot/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton<EfficiencyAnalysisService>();
builder.Services.AddSingleton<PrLinkService>();
builder.Services.AddSingleton<ScheduledTaskService>();
builder.Services.AddSingleton<SessionAnalyzerService>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Feature is completely inert: StartAsync never called (3/3 reviewers)

StartAsync() is never called from any initialization path, settings handler, or UI code. Neither EnableSessionAnalyzer nor SessionAnalyzerIntervalMinutes are read anywhere. The entire feature is dead code as shipped.

Fix: Wire up startup logic gated by EnableSessionAnalyzer (e.g., in CopilotService.InitializeAsync), or document that activation will be added in a follow-up PR.


#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
Expand Down
Loading