Skip to content

Commit f288004

Browse files
committed
Add fleet board live session monitor
1 parent d726e4a commit f288004

File tree

18 files changed

+694
-21
lines changed

18 files changed

+694
-21
lines changed

DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public sealed record SessionActivityDescriptor(
1212
public sealed record SessionActivitySnapshot(
1313
bool HasActiveSessions,
1414
int ActiveSessionCount,
15+
IReadOnlyList<SessionActivityDescriptor> ActiveSessions,
1516
SessionId? SessionId,
1617
string SessionTitle,
1718
AgentProfileId? AgentProfileId,

DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace DotPilot.Core.ChatSessions;
66
internal sealed class DebugChatClient(string agentName, TimeProvider timeProvider) : IChatClient
77
{
88
private const int DefaultChunkDelayMilliseconds = 45;
9-
private const int BrowserChunkDelayMilliseconds = 180;
9+
private const int BrowserChunkDelayMilliseconds = 400;
1010
private const string FallbackPrompt = "the latest request";
1111
private const string Newline = "\n";
1212

DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using DotPilot.Core.ControlPlaneDomain;
12
using Microsoft.Extensions.Logging;
23

34
namespace DotPilot.Core.ChatSessions;
@@ -7,6 +8,7 @@ internal sealed class SessionActivityMonitor(ILogger<SessionActivityMonitor> log
78
private static readonly SessionActivitySnapshot EmptySnapshot = new(
89
false,
910
0,
11+
[],
1012
null,
1113
string.Empty,
1214
null,
@@ -79,22 +81,43 @@ private void EndActivity(ActivityLease lease)
7981

8082
private SessionActivitySnapshot UpdateSnapshotUnsafe()
8183
{
84+
var activeSessions = GetActiveSessionsUnsafe();
8285
_current = _leases.Count == 0
8386
? EmptySnapshot
84-
: CreateSnapshot(_leases[^1].Descriptor, _leases.Count);
87+
: CreateSnapshot(activeSessions);
8588
return _current;
8689
}
8790

88-
private static SessionActivitySnapshot CreateSnapshot(SessionActivityDescriptor descriptor, int activeSessionCount)
91+
private IReadOnlyList<SessionActivityDescriptor> GetActiveSessionsUnsafe()
8992
{
93+
if (_leases.Count == 0)
94+
{
95+
return [];
96+
}
97+
98+
var descriptors = new Dictionary<SessionId, SessionActivityDescriptor>();
99+
for (var index = 0; index < _leases.Count; index++)
100+
{
101+
var descriptor = _leases[index].Descriptor;
102+
descriptors.Remove(descriptor.SessionId);
103+
descriptors[descriptor.SessionId] = descriptor;
104+
}
105+
106+
return [.. descriptors.Values];
107+
}
108+
109+
private static SessionActivitySnapshot CreateSnapshot(IReadOnlyList<SessionActivityDescriptor> activeSessions)
110+
{
111+
var latestSession = activeSessions[^1];
90112
return new SessionActivitySnapshot(
91113
true,
92-
activeSessionCount,
93-
descriptor.SessionId,
94-
descriptor.SessionTitle,
95-
descriptor.AgentProfileId,
96-
descriptor.AgentName,
97-
descriptor.ProviderDisplayName);
114+
activeSessions.Count,
115+
activeSessions,
116+
latestSession.SessionId,
117+
latestSession.SessionTitle,
118+
latestSession.AgentProfileId,
119+
latestSession.AgentName,
120+
latestSession.ProviderDisplayName);
98121
}
99122

100123
private sealed class ActivityLease(SessionActivityMonitor owner, SessionActivityDescriptor descriptor) : IDisposable

DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,190 @@ public async Task RefreshIgnoresCancellationDuringWorkspaceProbe()
100100
(await model.FeedbackMessage).Should().BeEmpty();
101101
}
102102

103+
[Test]
104+
public async Task FleetBoardShowsTheActiveSessionWhileStreamingAndClearsAfterCompletion()
105+
{
106+
await using var fixture = await CreateFixtureAsync();
107+
(await fixture.WorkspaceState.CreateAgentAsync(
108+
new CreateAgentProfileCommand(
109+
"Fleet Agent",
110+
AgentProviderKind.Debug,
111+
"debug-echo",
112+
"Stay deterministic for fleet board verification."),
113+
CancellationToken.None)).ShouldSucceed();
114+
var model = ActivatorUtilities.CreateInstance<ChatModel>(fixture.Provider);
115+
116+
await model.StartNewSession(CancellationToken.None);
117+
var selectedChat = await model.SelectedChat;
118+
119+
await using var enumerator = fixture.WorkspaceState.SendMessageAsync(
120+
new SendSessionMessageCommand(selectedChat!.Id, "fleet activity"),
121+
CancellationToken.None)
122+
.GetAsyncEnumerator(CancellationToken.None);
123+
124+
var observedLiveBoard = false;
125+
while (await enumerator.MoveNextAsync())
126+
{
127+
_ = enumerator.Current.ShouldSucceed();
128+
var board = await model.FleetBoard;
129+
board.Should().NotBeNull();
130+
if (board!.ActiveSessions.Count == 0)
131+
{
132+
continue;
133+
}
134+
135+
observedLiveBoard = true;
136+
board.Metrics.Should().Contain(metric =>
137+
metric.Label == "Live sessions" &&
138+
metric.Value == "1");
139+
board.ActiveSessions.Should().Contain(item =>
140+
item.Title == selectedChat.Title &&
141+
item.Summary.Contains("Fleet Agent", StringComparison.Ordinal));
142+
break;
143+
}
144+
145+
observedLiveBoard.Should().BeTrue();
146+
147+
while (await enumerator.MoveNextAsync())
148+
{
149+
_ = enumerator.Current.ShouldSucceed();
150+
}
151+
152+
FleetBoardView? completedBoard = null;
153+
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(2);
154+
while (DateTimeOffset.UtcNow < timeoutAt)
155+
{
156+
completedBoard = await model.FleetBoard;
157+
completedBoard.Should().NotBeNull();
158+
if (completedBoard!.ActiveSessions.Count == 0)
159+
{
160+
break;
161+
}
162+
163+
await Task.Delay(50);
164+
}
165+
166+
completedBoard.Should().NotBeNull();
167+
completedBoard!.ActiveSessions.Should().BeEmpty();
168+
completedBoard.ShowActiveSessionsEmptyState.Should().BeTrue();
169+
}
170+
171+
[Test]
172+
public async Task FleetBoardReusesTheWarmProviderSnapshotDuringLiveStreaming()
173+
{
174+
using var commandScope = CodexCliTestScope.Create(nameof(ChatModelTests));
175+
commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300);
176+
commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4");
177+
await using var fixture = await CreateFixtureAsync();
178+
(await fixture.WorkspaceState.CreateAgentAsync(
179+
new CreateAgentProfileCommand(
180+
"Fleet Agent",
181+
AgentProviderKind.Debug,
182+
"debug-echo",
183+
"Stay deterministic for fleet board verification."),
184+
CancellationToken.None)).ShouldSucceed();
185+
var model = ActivatorUtilities.CreateInstance<ChatModel>(fixture.Provider);
186+
187+
var initialBoard = await model.FleetBoard;
188+
initialBoard.Should().NotBeNull();
189+
var warmInvocationCount = commandScope.ReadInvocationCount("codex");
190+
191+
await model.StartNewSession(CancellationToken.None);
192+
var postStartInvocationCount = commandScope.ReadInvocationCount("codex");
193+
postStartInvocationCount.Should().BeGreaterThanOrEqualTo(warmInvocationCount);
194+
195+
var selectedChat = await model.SelectedChat;
196+
197+
await using var enumerator = fixture.WorkspaceState.SendMessageAsync(
198+
new SendSessionMessageCommand(selectedChat!.Id, "fleet activity"),
199+
CancellationToken.None)
200+
.GetAsyncEnumerator(CancellationToken.None);
201+
202+
var observedLiveBoard = false;
203+
while (await enumerator.MoveNextAsync())
204+
{
205+
_ = enumerator.Current.ShouldSucceed();
206+
var board = await model.FleetBoard;
207+
board.Should().NotBeNull();
208+
if (board!.ActiveSessions.Count == 0)
209+
{
210+
continue;
211+
}
212+
213+
observedLiveBoard = true;
214+
break;
215+
}
216+
217+
observedLiveBoard.Should().BeTrue();
218+
commandScope.ReadInvocationCount("codex").Should().Be(postStartInvocationCount);
219+
220+
while (await enumerator.MoveNextAsync())
221+
{
222+
_ = enumerator.Current.ShouldSucceed();
223+
}
224+
}
225+
226+
[Test]
227+
public async Task OpenFleetSessionSelectsTheRequestedActiveSession()
228+
{
229+
await using var fixture = await CreateFixtureAsync();
230+
var agent = (await fixture.WorkspaceState.CreateAgentAsync(
231+
new CreateAgentProfileCommand(
232+
"Navigator Agent",
233+
AgentProviderKind.Debug,
234+
"debug-echo",
235+
"Stay deterministic for fleet navigation verification."),
236+
CancellationToken.None)).ShouldSucceed();
237+
var firstSession = (await fixture.WorkspaceState.CreateSessionAsync(
238+
new CreateSessionCommand("Fleet Session One", agent.Id),
239+
CancellationToken.None)).ShouldSucceed();
240+
var secondSession = (await fixture.WorkspaceState.CreateSessionAsync(
241+
new CreateSessionCommand("Fleet Session Two", agent.Id),
242+
CancellationToken.None)).ShouldSucceed();
243+
var model = ActivatorUtilities.CreateInstance<ChatModel>(fixture.Provider);
244+
await model.SelectedChat.UpdateAsync(
245+
_ => new SessionSidebarItem(secondSession.Session.Id, secondSession.Session.Title, secondSession.Session.Preview),
246+
CancellationToken.None);
247+
248+
await using var enumerator = fixture.WorkspaceState.SendMessageAsync(
249+
new SendSessionMessageCommand(firstSession.Session.Id, "jump back to this"),
250+
CancellationToken.None)
251+
.GetAsyncEnumerator(CancellationToken.None);
252+
253+
FleetBoardSessionItem? activeSession = null;
254+
while (await enumerator.MoveNextAsync())
255+
{
256+
_ = enumerator.Current.ShouldSucceed();
257+
var board = await model.FleetBoard;
258+
board.Should().NotBeNull();
259+
activeSession = board!.ActiveSessions.FirstOrDefault(item =>
260+
item.Title == firstSession.Session.Title);
261+
if (activeSession is not null)
262+
{
263+
break;
264+
}
265+
}
266+
267+
activeSession.Should().NotBeNull();
268+
await model.OpenFleetSession(activeSession!.OpenRequest, CancellationToken.None);
269+
270+
var selectedChat = await model.SelectedChat;
271+
selectedChat.Should().NotBeNull();
272+
selectedChat!.Id.Should().Be(firstSession.Session.Id);
273+
selectedChat.Title.Should().Be(firstSession.Session.Title);
274+
275+
while (await enumerator.MoveNextAsync())
276+
{
277+
_ = enumerator.Current.ShouldSucceed();
278+
}
279+
}
280+
103281
private static async Task<TestFixture> CreateFixtureAsync()
104282
{
105283
var services = new ServiceCollection();
106284
services.AddSingleton(TimeProvider.System);
107285
services.AddSingleton<WorkspaceProjectionNotifier>();
286+
services.AddSingleton<UiDispatcher>();
108287
services.AddSingleton<IOperatorPreferencesStore, LocalOperatorPreferencesStore>();
109288
services.AddAgentSessions(new AgentSessionStorageOptions
110289
{

DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public sealed class GivenChatSessionsShell : TestBase
4242
private const string ChatComposerInputAutomationId = "ChatComposerInput";
4343
private const string ChatComposerHintAutomationId = "ChatComposerHint";
4444
private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton";
45+
private const string ChatFleetBoardSectionAutomationId = "ChatFleetBoardSection";
46+
private const string ChatFleetMetricItemAutomationId = "ChatFleetMetricItem";
47+
private const string ChatFleetSessionItemAutomationId = "ChatFleetSessionItem";
48+
private const string ChatFleetProviderItemAutomationId = "ChatFleetProviderItem";
49+
private const string ChatFleetEmptyStateAutomationId = "ChatFleetEmptyState";
4550
private const string ComposerBehaviorSectionAutomationId = "ComposerBehaviorSection";
4651
private const string ComposerBehaviorCurrentHintAutomationId = "ComposerBehaviorCurrentHint";
4752
private const string ComposerBehaviorEnterInsertsNewLineButtonAutomationId = "ComposerBehaviorEnterInsertsNewLineButton";
@@ -132,11 +137,38 @@ public async Task WhenLiveGenerationStartsThenSidebarShowsTheLiveSessionIndicato
132137
WaitForElement(AppSidebarLiveSessionIndicatorAutomationId);
133138
WaitForTextContains(AppSidebarLiveSessionTitleAutomationId, "Live session active", ScreenTransitionTimeout);
134139
WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout);
135-
WaitForAutomationElementToDisappearById(AppSidebarLiveSessionIndicatorAutomationId);
140+
WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout);
141+
WaitForAutomationElementToDisappearById(AppSidebarLiveSessionIndicatorAutomationId, ScreenTransitionTimeout);
136142

137143
TakeScreenshot("sidebar_live_session_indicator");
138144
}
139145

146+
[Test]
147+
public async Task WhenLiveGenerationStartsThenFleetBoardShowsTheActiveSessionAndProviderHealth()
148+
{
149+
await Task.CompletedTask;
150+
151+
EnsureOnChatScreen();
152+
WaitForElement(ChatFleetBoardSectionAutomationId);
153+
WaitForTextContains(ChatFleetEmptyStateAutomationId, "No live sessions right now.", ScreenTransitionTimeout);
154+
ClickActionAutomationElement(ChatStartNewButtonAutomationId);
155+
WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout);
156+
157+
ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt);
158+
PressEnterAutomationElement(ChatComposerInputAutomationId);
159+
160+
WaitForTextContains(ChatFleetMetricItemAutomationId, "Live sessions", ScreenTransitionTimeout);
161+
WaitForTextContains(ChatFleetSessionItemAutomationId, DefaultSessionTitle, ScreenTransitionTimeout);
162+
WaitForTextContains(ChatFleetProviderItemAutomationId, "Debug Provider", ScreenTransitionTimeout);
163+
WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout);
164+
WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout);
165+
WaitForAutomationElementToDisappearById(AppSidebarLiveSessionIndicatorAutomationId, ScreenTransitionTimeout);
166+
WaitForAutomationElementToDisappearById(ChatFleetSessionItemAutomationId, ScreenTransitionTimeout);
167+
WaitForTextContains(ChatFleetEmptyStateAutomationId, "No live sessions right now.", ScreenTransitionTimeout);
168+
169+
TakeScreenshot("chat_fleet_board_live_session");
170+
}
171+
140172
[Test]
141173
public async Task WhenOpeningAgentsThenCatalogIsVisible()
142174
{

DotPilot.UITests/Harness/TestBase.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ private bool DidBrowserActionTakeEffect(
587587
return true;
588588
}
589589

590-
return WaitForAutomationElementToDisappear(automationId);
590+
return WaitForAutomationElementToDisappear(automationId, PostClickTransitionProbeTimeout);
591591
}
592592

593593
private static bool WaitForActionEffect(Func<bool> effectObserved)
@@ -626,9 +626,9 @@ private static bool WaitForActionEffect(Func<bool> effectObserved)
626626
}
627627
}
628628

629-
private bool WaitForAutomationElementToDisappear(string automationId)
629+
private bool WaitForAutomationElementToDisappear(string automationId, TimeSpan timeout)
630630
{
631-
var timeoutAt = DateTimeOffset.UtcNow.Add(PostClickTransitionProbeTimeout);
631+
var timeoutAt = DateTimeOffset.UtcNow.Add(timeout);
632632
while (DateTimeOffset.UtcNow < timeoutAt)
633633
{
634634
if (!BrowserHasAutomationElement(automationId))
@@ -667,10 +667,15 @@ protected void PressModifierEnterAutomationElement(string automationId)
667667
}
668668

669669
protected void WaitForAutomationElementToDisappearById(string automationId)
670+
{
671+
WaitForAutomationElementToDisappearById(automationId, PostClickTransitionProbeTimeout);
672+
}
673+
674+
protected void WaitForAutomationElementToDisappearById(string automationId, TimeSpan timeout)
670675
{
671676
ArgumentException.ThrowIfNullOrWhiteSpace(automationId);
672677

673-
if (WaitForAutomationElementToDisappear(automationId))
678+
if (WaitForAutomationElementToDisappear(automationId, timeout))
674679
{
675680
return;
676681
}

DotPilot/App.xaml.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Diagnostics.CodeAnalysis;
2-
using DotPilot.Core.ChatSessions;
32
using Microsoft.Extensions.DependencyInjection;
43
using Microsoft.Extensions.Hosting;
54
using Microsoft.Extensions.Logging;

DotPilot/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
global using DotPilot.Core.ChatSessions;
12
global using DotPilot.Core.ChatSessions.Commands;
23
global using DotPilot.Core.ChatSessions.Contracts;
34
global using DotPilot.Core.ChatSessions.Models;

DotPilot/Host/Power/DesktopSleepPreventionService.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System.ComponentModel;
22
using System.Diagnostics;
33
using System.Runtime.InteropServices;
4-
using DotPilot.Core.ChatSessions;
54
using Microsoft.Extensions.Logging;
65

76
namespace DotPilot;

0 commit comments

Comments
 (0)