Skip to content
Merged
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
10 changes: 5 additions & 5 deletions PolyPilot.Console/Models/AgentSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

namespace PolyPilot.Models;

public record ChatMessage(string Role, string Content, DateTime Timestamp);
public record ChatMessage(string Role, string Content, DateTimeOffset Timestamp);

public class AgentSession : IAsyncDisposable
{
public string Name { get; }
public string Model { get; }
public DateTime CreatedAt { get; }
public DateTimeOffset CreatedAt { get; }
public List<ChatMessage> History { get; } = new();
public bool IsProcessing { get; private set; }
public string? SessionId { get; }
Expand All @@ -26,7 +26,7 @@ public AgentSession(string name, string model, CopilotSession session, string? s
{
Name = name;
Model = model;
CreatedAt = DateTime.Now;
CreatedAt = DateTimeOffset.UtcNow;
SessionId = sessionId;
IsResumed = isResumed;
_session = session;
Expand Down Expand Up @@ -78,7 +78,7 @@ private void CompleteResponse()
var response = _currentResponse.ToString();
if (!string.IsNullOrEmpty(response))
{
History.Add(new ChatMessage("assistant", response, DateTime.Now));
History.Add(new ChatMessage("assistant", response, DateTimeOffset.UtcNow));
}
_responseCompletion?.TrySetResult(response);
_currentResponse.Clear();
Expand All @@ -98,7 +98,7 @@ public async Task<string> SendPromptAsync(string prompt, CancellationToken cance
_currentResponse.Clear();
_hasReceivedDeltasThisTurn = false;

History.Add(new ChatMessage("user", prompt, DateTime.Now));
History.Add(new ChatMessage("user", prompt, DateTimeOffset.UtcNow));

try
{
Expand Down
141 changes: 141 additions & 0 deletions PolyPilot.IntegrationTests/QuotaDisplayTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using PolyPilot.IntegrationTests.Fixtures;

namespace PolyPilot.IntegrationTests;

/// <summary>
/// Integration tests for the quota display feature.
/// Creates a session, sends a message to trigger AssistantUsageEvent with quota data,
/// then verifies the quota indicator appears in the Dashboard header.
/// </summary>
[Collection("PolyPilot")]
[Trait("Category", "QuotaDisplay")]
public class QuotaDisplayTests : IntegrationTestBase
{
public QuotaDisplayTests(AppFixture app, ITestOutputHelper output)
: base(app, output) { }

[Fact]
public async Task QuotaIndicator_AppearsAfterSendingMessage()
{
await WaitForCdpReadyAsync();
await ScreenshotAsync("quota-01-before");

// Step 1: Create a new session by clicking "+" → "Session"
await ClickAsync("[title='Create new session'], button.new-session-btn");
await Task.Delay(1000);

var menuItem = await CdpEvalAsync(
"const items = [...document.querySelectorAll('.sidebar-new-menu-item, .popover-item, button')]; " +
"const btn = items.find(b => b.textContent?.includes('Session') || b.textContent?.includes('Empty')); " +
"btn?.click(); btn ? 'clicked: ' + btn.textContent?.trim()?.substring(0,20) : 'no session btn'");
Output.WriteLine($"Create session: {menuItem}");
await Task.Delay(2000);

// Step 2: Find the input area and type a message
var fillResult = await CdpEvalAsync(
"const sel = '.card-input input, .card-input textarea, .input-row textarea, textarea'; " +
"const input = [...document.querySelectorAll(sel)].find(i => i.offsetParent !== null); " +
"if (input) { input.value = 'Say hello'; " +
"input.dispatchEvent(new Event('input', {bubbles:true})); " +
"input.dispatchEvent(new Event('change', {bubbles:true})); 'filled'; } else { 'no input'; }");
Output.WriteLine($"Fill input: {fillResult}");

// Step 3: Click send
var sendResult = await CdpEvalAsync(
"const sel = '.card-input input, .card-input textarea, .input-row textarea, textarea'; " +
"const input = [...document.querySelectorAll(sel)].find(i => i.offsetParent !== null); " +
"if (input) { const container = input.closest('.card-input') || input.closest('.input-row'); " +
"const sendBtn = container?.querySelector('.send-btn:not(.stop-btn)') || " +
"container?.querySelectorAll('button')?.[container?.querySelectorAll('button')?.length - 1]; " +
"if (sendBtn) { sendBtn.click(); 'sent: ' + sendBtn.className; } " +
"else { input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); 'enter'; } " +
"} else { 'no input'; }");
Output.WriteLine($"Send: {sendResult}");

// Step 4: Wait for response + quota event (up to 60 seconds)
Output.WriteLine("Waiting for Copilot response + quota data...");
var hasQuota = false;
for (var i = 0; i < 30; i++)
{
var quotaExists = await ExistsAsync("#quota-indicator, .quota-indicator, .quota-pill");
if (quotaExists)
{
hasQuota = true;
Output.WriteLine($"Quota indicator appeared after {i * 2}s");
break;
}
if (i % 5 == 0)
{
var pageState = await CdpEvalAsync(
"JSON.stringify({quota: !!document.querySelector('#quota-indicator, .quota-indicator'), " +
"messages: document.querySelectorAll('.message-content, .markdown-body').length, " +
"processing: !!document.querySelector('.thinking, .processing, .spinner')})");
Output.WriteLine($"Poll {i * 2}s: {pageState}");
}
await Task.Delay(2000);
}

await ScreenshotAsync("quota-02-after-message");

if (hasQuota)
{
// Verify quota indicator content
var quotaText = await GetTextAsync("#quota-indicator, .quota-indicator, .quota-pill");
Output.WriteLine($"Quota indicator text: '{quotaText}'");
Assert.False(string.IsNullOrWhiteSpace(quotaText), "Quota indicator should show percentage");

await ScreenshotAsync("quota-03-indicator-visible");
}
else
{
Output.WriteLine("Quota indicator did not appear — may need more messages or token may not have quota data");
// Don't fail — the test documents the behavior. Quota depends on the account's plan.
}
}

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

var dashboardLoaded = await ExistsAsync(".dashboard, #scheduled-tasks-page, .session-item");
Assert.True(dashboardLoaded, "Dashboard should render without errors");

await ScreenshotAsync("quota-dashboard");
}

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

// Navigate to a session if not already in one
var hasSession = await ExistsAsync(".session-item, .session-list-item");
if (hasSession)
{
await ClickAsync(".session-item, .session-list-item");
await Task.Delay(2000);
}

// Type /usage command
var fillResult = await CdpEvalAsync(
"const sel = '.card-input input, .card-input textarea, .input-row textarea, textarea'; " +
"const input = [...document.querySelectorAll(sel)].find(i => i.offsetParent !== null); " +
"if (input) { input.value = '/usage'; " +
"input.dispatchEvent(new Event('input', {bubbles:true})); " +
"input.dispatchEvent(new Event('change', {bubbles:true})); 'filled'; } else { 'no input'; }");
Output.WriteLine($"Fill /usage: {fillResult}");

if (fillResult == "filled")
{
// Send it
await CdpEvalAsync(
"const sel = '.card-input input, .card-input textarea, .input-row textarea, textarea'; " +
"const input = [...document.querySelectorAll(sel)].find(i => i.offsetParent !== null); " +
"if (input) { input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); }");
await Task.Delay(2000);

await ScreenshotAsync("quota-usage-command");
}
}
}
2 changes: 1 addition & 1 deletion PolyPilot.Tests/AgentSessionInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public void MessageQueue_CanEnqueueAndDequeue()
[Fact]
public void Properties_CanBeSet()
{
var now = DateTime.Now;
var now = DateTimeOffset.UtcNow;
var session = new AgentSessionInfo
{
Name = "my-session",
Expand Down
86 changes: 75 additions & 11 deletions PolyPilot.Tests/ChatMessageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ public void ReflectionMessage_SetsReflectionType()
public void Constructor_UserRole_OverridesMessageType()
{
// When role is "user", MessageType should always be User regardless of what's passed
var msg = new ChatMessage("user", "test", DateTime.Now, ChatMessageType.Assistant);
var msg = new ChatMessage("user", "test", DateTimeOffset.UtcNow, ChatMessageType.Assistant);
Assert.Equal(ChatMessageType.User, msg.MessageType);
}

[Fact]
public void Constructor_AssistantRole_WithUserType_CorrectToAssistant()
{
// When role is not "user" but messageType is User, it should correct to Assistant
var msg = new ChatMessage("assistant", "test", DateTime.Now, ChatMessageType.User);
var msg = new ChatMessage("assistant", "test", DateTimeOffset.UtcNow, ChatMessageType.User);
Assert.Equal(ChatMessageType.Assistant, msg.MessageType);
}

Expand Down Expand Up @@ -151,14 +151,14 @@ public void Model_DefaultsToNull()
[Fact]
public void Model_CanBeSetViaInitializer()
{
var msg = new ChatMessage("assistant", "test", DateTime.Now) { Model = "gpt-4.1" };
var msg = new ChatMessage("assistant", "test", DateTimeOffset.UtcNow) { Model = "gpt-4.1" };
Assert.Equal("gpt-4.1", msg.Model);
}

[Fact]
public void Model_PreservedOnAssistantMessages()
{
var msg = new ChatMessage("assistant", "response", DateTime.Now) { Model = "claude-sonnet-4.5" };
var msg = new ChatMessage("assistant", "response", DateTimeOffset.UtcNow) { Model = "claude-sonnet-4.5" };
Assert.True(msg.IsAssistant);
Assert.Equal("claude-sonnet-4.5", msg.Model);
}
Expand Down Expand Up @@ -190,7 +190,7 @@ public void OriginalContent_CanBeSet()
[Fact]
public void OriginalContent_PreservedOnDeserialization()
{
var msg = new ChatMessage("user", "full orchestration prompt", DateTime.Now)
var msg = new ChatMessage("user", "full orchestration prompt", DateTimeOffset.UtcNow)
{
OriginalContent = "user typed this"
};
Expand Down Expand Up @@ -282,8 +282,8 @@ public void ElapsedDisplay_LessThanOneSecond_ShowsLessThan1s()
{
var activity = new ToolActivity
{
StartedAt = DateTime.Now,
CompletedAt = DateTime.Now.AddMilliseconds(500)
StartedAt = DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow.AddMilliseconds(500)
};
Assert.Equal("<1s", activity.ElapsedDisplay);
}
Expand All @@ -293,8 +293,8 @@ public void ElapsedDisplay_MultipleSeconds_ShowsRoundedSeconds()
{
var activity = new ToolActivity
{
StartedAt = DateTime.Now.AddSeconds(-5),
CompletedAt = DateTime.Now
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
CompletedAt = DateTimeOffset.UtcNow
};
Assert.Equal("5s", activity.ElapsedDisplay);
}
Expand All @@ -304,11 +304,75 @@ public void ElapsedDisplay_NotCompleted_UsesCurrentTime()
{
var activity = new ToolActivity
{
StartedAt = DateTime.Now.AddSeconds(-2),
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-2),
CompletedAt = null
};
// Should be ~2s since it measures against DateTime.Now
// Should be ~2s since it measures against DateTimeOffset.UtcNow
var display = activity.ElapsedDisplay;
Assert.Matches(@"^\d+s$", display);
}

[Fact]
public void FactoryMethods_UseUtcTimestamps()
{
// All factory methods should produce UTC timestamps (issue #386)
var before = DateTimeOffset.UtcNow;
var user = ChatMessage.UserMessage("test");
var assistant = ChatMessage.AssistantMessage("test");
var system = ChatMessage.SystemMessage("test");
var error = ChatMessage.ErrorMessage("test");
var after = DateTimeOffset.UtcNow;

Assert.Equal(TimeSpan.Zero, user.Timestamp.Offset);
Assert.Equal(TimeSpan.Zero, assistant.Timestamp.Offset);
Assert.Equal(TimeSpan.Zero, system.Timestamp.Offset);
Assert.Equal(TimeSpan.Zero, error.Timestamp.Offset);

Assert.InRange(user.Timestamp, before, after);
Assert.InRange(assistant.Timestamp, before, after);
}

[Fact]
public void Timestamp_IsDateTimeOffset()
{
// ChatMessage.Timestamp should be DateTimeOffset, not DateTime (issue #386)
var msg = ChatMessage.UserMessage("test");
Assert.IsType<DateTimeOffset>(msg.Timestamp);
}

[Fact]
public void Timestamp_CrossTimezoneComparison_Works()
{
// The core bug: comparing UTC dispatch time with local message timestamps.
// With DateTimeOffset, this comparison is timezone-aware.
var utcTime = new DateTimeOffset(2026, 4, 23, 15, 0, 0, TimeSpan.Zero);
var localTime = new DateTimeOffset(2026, 4, 23, 11, 0, 0, TimeSpan.FromHours(-4)); // Same instant, different offset

var msg = new ChatMessage("user", "test", localTime);
// These represent the same instant — comparison should be equal
Assert.True(msg.Timestamp >= utcTime);
Assert.True(msg.Timestamp <= utcTime);
}

[Fact]
public void ToolActivity_UsesDateTimeOffset()
{
var activity = new ToolActivity
{
Name = "test",
StartedAt = DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow.AddSeconds(5)
};
Assert.Equal(TimeSpan.Zero, activity.StartedAt.Offset);
Assert.Equal(TimeSpan.Zero, activity.CompletedAt!.Value.Offset);
Assert.Equal("5s", activity.ElapsedDisplay);
}

[Fact]
public void DefaultConstructor_ProducesUtcTimestamp()
{
// Parameterless constructor (used by JSON deserialization) should produce UTC
var msg = new ChatMessage();
Assert.Equal(TimeSpan.Zero, msg.Timestamp.Offset);
}
}
4 changes: 2 additions & 2 deletions PolyPilot.Tests/ConsecutiveStuckSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ public async Task HistorySize_DoesNotGrow_AfterRepeatedStucks()
for (int i = 0; i < 200; i++)
{
session.History.Add(new ChatMessage(i % 2 == 0 ? "user" : "assistant",
$"Message {i}", DateTime.Now));
$"Message {i}", DateTimeOffset.UtcNow));
}
var initialCount = session.History.Count;

Expand Down Expand Up @@ -320,7 +320,7 @@ public void RepeatedStuckMessage_SuggestsNewSession()
// The error message for repeated stucks should suggest creating a new session
var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
for (int i = 0; i < 200; i++)
info.History.Add(new ChatMessage("user", $"msg {i}", DateTime.Now));
info.History.Add(new ChatMessage("user", $"msg {i}", DateTimeOffset.UtcNow));
info.ConsecutiveStuckCount = 3;

// Simulate the message format from the watchdog
Expand Down
Loading
Loading