Skip to content

Commit d726e4a

Browse files
committed
Prevent desktop sleep during live sessions
1 parent 0e46ae0 commit d726e4a

File tree

19 files changed

+759
-9
lines changed

19 files changed

+759
-9
lines changed

DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public static IServiceCollection AddAgentSessions(
2121
services.AddSingleton<IAgentProviderStatusReader, AgentProviderStatusReader>();
2222
services.AddSingleton<AgentPromptDraftGenerator>();
2323
services.AddSingleton<AgentExecutionLoggingMiddleware>();
24+
services.AddSingleton<ISessionActivityMonitor, SessionActivityMonitor>();
2425
services.AddSingleton<AgentRuntimeConversationFactory>();
2526
services.AddSingleton<IAgentSessionService, AgentSessionService>();
2627
services.AddSingleton<IAgentWorkspaceState, AgentWorkspaceState>();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using DotPilot.Core.ControlPlaneDomain;
2+
3+
namespace DotPilot.Core.ChatSessions.Contracts;
4+
5+
public sealed record SessionActivityDescriptor(
6+
SessionId SessionId,
7+
string SessionTitle,
8+
AgentProfileId AgentProfileId,
9+
string AgentName,
10+
string ProviderDisplayName);
11+
12+
public sealed record SessionActivitySnapshot(
13+
bool HasActiveSessions,
14+
int ActiveSessionCount,
15+
SessionId? SessionId,
16+
string SessionTitle,
17+
AgentProfileId? AgentProfileId,
18+
string AgentName,
19+
string ProviderDisplayName);

DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,26 @@ internal static partial class StartupWorkspaceHydrationLog
320320
Message = "Startup workspace hydration failed.")]
321321
public static partial void HydrationFailed(ILogger logger, Exception exception);
322322
}
323+
324+
internal static partial class SessionActivityMonitorLog
325+
{
326+
[LoggerMessage(
327+
EventId = 1303,
328+
Level = LogLevel.Information,
329+
Message = "Marked session activity as live. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")]
330+
public static partial void ActivityStarted(
331+
ILogger logger,
332+
Guid sessionId,
333+
Guid agentId,
334+
int activeSessionCount);
335+
336+
[LoggerMessage(
337+
EventId = 1304,
338+
Level = LogLevel.Information,
339+
Message = "Released session live activity. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")]
340+
public static partial void ActivityCompleted(
341+
ILogger logger,
342+
Guid sessionId,
343+
Guid agentId,
344+
int activeSessionCount);
345+
}

DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ namespace DotPilot.Core.ChatSessions;
1010
internal sealed class AgentSessionService(
1111
IDbContextFactory<LocalAgentSessionDbContext> dbContextFactory,
1212
AgentExecutionLoggingMiddleware executionLoggingMiddleware,
13+
ISessionActivityMonitor sessionActivityMonitor,
1314
IAgentProviderStatusReader providerStatusReader,
1415
AgentRuntimeConversationFactory runtimeConversationFactory,
1516
TimeProvider timeProvider,
@@ -556,6 +557,14 @@ public async IAsyncEnumerable<Result<SessionStreamEntry>> SendMessageAsync(
556557
yield break;
557558
}
558559

560+
using var liveActivity = sessionActivityMonitor.BeginActivity(
561+
new SessionActivityDescriptor(
562+
command.SessionId,
563+
session.Title,
564+
new AgentProfileId(agent.Id),
565+
agent.Name,
566+
providerProfile.DisplayName));
567+
559568
Result<RuntimeConversationContext> runtimeConversationResult;
560569
try
561570
{

DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ namespace DotPilot.Core.ChatSessions;
55

66
internal sealed class DebugChatClient(string agentName, TimeProvider timeProvider) : IChatClient
77
{
8-
private const int ChunkDelayMilliseconds = 45;
8+
private const int DefaultChunkDelayMilliseconds = 45;
9+
private const int BrowserChunkDelayMilliseconds = 180;
910
private const string FallbackPrompt = "the latest request";
1011
private const string Newline = "\n";
1112

@@ -44,7 +45,7 @@ public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
4445
foreach (var chunk in SplitIntoChunks(responseText))
4546
{
4647
cancellationToken.ThrowIfCancellationRequested();
47-
await Task.Delay(ChunkDelayMilliseconds, cancellationToken);
48+
await Task.Delay(GetChunkDelayMilliseconds(), cancellationToken);
4849

4950
yield return new ChatResponseUpdate(ChatRole.Assistant, chunk)
5051
{
@@ -104,5 +105,11 @@ private static string CreateResponseText(IEnumerable<ChatMessage> messages)
104105
"Tool activity is simulated inline before the final assistant answer completes.",
105106
]);
106107
}
107-
}
108108

109+
private static int GetChunkDelayMilliseconds()
110+
{
111+
return OperatingSystem.IsBrowser()
112+
? BrowserChunkDelayMilliseconds
113+
: DefaultChunkDelayMilliseconds;
114+
}
115+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace DotPilot.Core.ChatSessions;
4+
5+
internal sealed class SessionActivityMonitor(ILogger<SessionActivityMonitor> logger) : ISessionActivityMonitor
6+
{
7+
private static readonly SessionActivitySnapshot EmptySnapshot = new(
8+
false,
9+
0,
10+
null,
11+
string.Empty,
12+
null,
13+
string.Empty,
14+
string.Empty);
15+
16+
private readonly Lock _gate = new();
17+
private readonly List<ActivityLease> _leases = [];
18+
private SessionActivitySnapshot _current = EmptySnapshot;
19+
20+
public SessionActivitySnapshot Current
21+
{
22+
get
23+
{
24+
lock (_gate)
25+
{
26+
return _current;
27+
}
28+
}
29+
}
30+
31+
public event EventHandler? StateChanged;
32+
33+
public IDisposable BeginActivity(SessionActivityDescriptor descriptor)
34+
{
35+
ArgumentNullException.ThrowIfNull(descriptor);
36+
37+
ActivityLease lease;
38+
int activeSessionCount;
39+
lock (_gate)
40+
{
41+
lease = new ActivityLease(this, descriptor);
42+
_leases.Add(lease);
43+
activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount;
44+
}
45+
46+
SessionActivityMonitorLog.ActivityStarted(
47+
logger,
48+
descriptor.SessionId.Value,
49+
descriptor.AgentProfileId.Value,
50+
activeSessionCount);
51+
StateChanged?.Invoke(this, EventArgs.Empty);
52+
return lease;
53+
}
54+
55+
private void EndActivity(ActivityLease lease)
56+
{
57+
ArgumentNullException.ThrowIfNull(lease);
58+
59+
int activeSessionCount;
60+
lock (_gate)
61+
{
62+
var index = _leases.IndexOf(lease);
63+
if (index < 0)
64+
{
65+
return;
66+
}
67+
68+
_leases.RemoveAt(index);
69+
activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount;
70+
}
71+
72+
SessionActivityMonitorLog.ActivityCompleted(
73+
logger,
74+
lease.Descriptor.SessionId.Value,
75+
lease.Descriptor.AgentProfileId.Value,
76+
activeSessionCount);
77+
StateChanged?.Invoke(this, EventArgs.Empty);
78+
}
79+
80+
private SessionActivitySnapshot UpdateSnapshotUnsafe()
81+
{
82+
_current = _leases.Count == 0
83+
? EmptySnapshot
84+
: CreateSnapshot(_leases[^1].Descriptor, _leases.Count);
85+
return _current;
86+
}
87+
88+
private static SessionActivitySnapshot CreateSnapshot(SessionActivityDescriptor descriptor, int activeSessionCount)
89+
{
90+
return new SessionActivitySnapshot(
91+
true,
92+
activeSessionCount,
93+
descriptor.SessionId,
94+
descriptor.SessionTitle,
95+
descriptor.AgentProfileId,
96+
descriptor.AgentName,
97+
descriptor.ProviderDisplayName);
98+
}
99+
100+
private sealed class ActivityLease(SessionActivityMonitor owner, SessionActivityDescriptor descriptor) : IDisposable
101+
{
102+
private int _disposed;
103+
104+
public SessionActivityDescriptor Descriptor { get; } = descriptor;
105+
106+
public void Dispose()
107+
{
108+
if (Interlocked.Exchange(ref _disposed, 1) != 0)
109+
{
110+
return;
111+
}
112+
113+
owner.EndActivity(this);
114+
}
115+
}
116+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace DotPilot.Core.ChatSessions;
2+
3+
public interface ISessionActivityMonitor
4+
{
5+
SessionActivitySnapshot Current { get; }
6+
7+
event EventHandler? StateChanged;
8+
9+
IDisposable BeginActivity(SessionActivityDescriptor descriptor);
10+
}

DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,48 @@ public async Task SendMessageAsyncStreamsDebugEntriesAndPersistsTranscript()
248248
entry.Text.Contains("Debug workflow finished", StringComparison.Ordinal));
249249
}
250250

251+
[Test]
252+
public async Task SendMessageAsyncMarksTheSessionAsLiveWhileStreamingIsActive()
253+
{
254+
await using var fixture = CreateFixture();
255+
var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Live Agent");
256+
var session = (await fixture.Service.CreateSessionAsync(
257+
new CreateSessionCommand("Live session", agent.Id),
258+
CancellationToken.None)).ShouldSucceed();
259+
260+
fixture.SessionActivityMonitor.Current.HasActiveSessions.Should().BeFalse();
261+
262+
await using var enumerator = fixture.Service.SendMessageAsync(
263+
new SendSessionMessageCommand(session.Session.Id, "hello from live monitor"),
264+
CancellationToken.None)
265+
.GetAsyncEnumerator(CancellationToken.None);
266+
267+
var observedLiveState = false;
268+
while (await enumerator.MoveNextAsync())
269+
{
270+
_ = enumerator.Current.ShouldSucceed();
271+
if (!fixture.SessionActivityMonitor.Current.HasActiveSessions)
272+
{
273+
continue;
274+
}
275+
276+
observedLiveState = true;
277+
fixture.SessionActivityMonitor.Current.SessionId.Should().Be(session.Session.Id);
278+
fixture.SessionActivityMonitor.Current.AgentProfileId.Should().Be(agent.Id);
279+
fixture.SessionActivityMonitor.Current.AgentName.Should().Be("Live Agent");
280+
break;
281+
}
282+
283+
observedLiveState.Should().BeTrue();
284+
285+
while (await enumerator.MoveNextAsync())
286+
{
287+
_ = enumerator.Current.ShouldSucceed();
288+
}
289+
290+
fixture.SessionActivityMonitor.Current.HasActiveSessions.Should().BeFalse();
291+
}
292+
251293
[Test]
252294
public async Task SendMessageAsyncStreamsDebugEntriesWhenTransientRuntimeConversationIsPreferred()
253295
{
@@ -584,6 +626,9 @@ private sealed class TestFixture(ServiceProvider provider, IAgentSessionService
584626

585627
public IAgentSessionService Service { get; } = service;
586628

629+
public ISessionActivityMonitor SessionActivityMonitor { get; } =
630+
provider.GetRequiredService<ISessionActivityMonitor>();
631+
587632
public ValueTask DisposeAsync()
588633
{
589634
return DisposeAsyncCore();

DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,55 @@ public async Task StartupOverlayRemainsVisibleUntilWorkspaceHydrationCompletes()
3030
viewModel.StartupOverlayVisibility.Should().Be(Visibility.Collapsed);
3131
}
3232

33+
[Test]
34+
public async Task LiveSessionIndicatorAppearsWhileStreamingAndCollapsesAfterCompletion()
35+
{
36+
await using var fixture = CreateFixture();
37+
var viewModel = fixture.Provider.GetRequiredService<ShellViewModel>();
38+
39+
var agent = (await fixture.Service.CreateAgentAsync(
40+
new CreateAgentProfileCommand(
41+
"Sleep Agent",
42+
AgentProviderKind.Debug,
43+
"debug-echo",
44+
"Stay deterministic while testing the shell indicator.",
45+
"Shell indicator test agent."),
46+
CancellationToken.None)).ShouldSucceed();
47+
var session = (await fixture.Service.CreateSessionAsync(
48+
new CreateSessionCommand("Session with Sleep Agent", agent.Id),
49+
CancellationToken.None)).ShouldSucceed();
50+
51+
await using var enumerator = fixture.Service.SendMessageAsync(
52+
new SendSessionMessageCommand(session.Session.Id, "hello from shell test"),
53+
CancellationToken.None)
54+
.GetAsyncEnumerator(CancellationToken.None);
55+
56+
var observedIndicator = false;
57+
while (await enumerator.MoveNextAsync())
58+
{
59+
_ = enumerator.Current.ShouldSucceed();
60+
if (viewModel.LiveSessionIndicatorVisibility != Visibility.Visible)
61+
{
62+
continue;
63+
}
64+
65+
observedIndicator = true;
66+
viewModel.LiveSessionIndicatorTitle.Should().Be("Live session active");
67+
viewModel.LiveSessionIndicatorSummary.Should().Contain("Sleep Agent");
68+
viewModel.LiveSessionIndicatorSummary.Should().Contain("Session with Sleep Agent");
69+
break;
70+
}
71+
72+
observedIndicator.Should().BeTrue();
73+
74+
while (await enumerator.MoveNextAsync())
75+
{
76+
_ = enumerator.Current.ShouldSucceed();
77+
}
78+
79+
viewModel.LiveSessionIndicatorVisibility.Should().Be(Visibility.Collapsed);
80+
}
81+
3382
private static TestFixture CreateFixture()
3483
{
3584
var services = new ServiceCollection();
@@ -40,6 +89,8 @@ private static TestFixture CreateFixture()
4089
UseInMemoryDatabase = true,
4190
InMemoryDatabaseName = Guid.NewGuid().ToString("N"),
4291
});
92+
services.AddSingleton<UiDispatcher>();
93+
services.AddSingleton<DesktopSleepPreventionService>();
4394
services.AddSingleton<ShellViewModel>();
4495

4596
var provider = services.BuildServiceProvider();
@@ -50,6 +101,8 @@ private sealed class TestFixture(ServiceProvider provider) : IAsyncDisposable
50101
{
51102
public ServiceProvider Provider { get; } = provider;
52103

104+
public IAgentSessionService Service { get; } = provider.GetRequiredService<IAgentSessionService>();
105+
53106
public ValueTask DisposeAsync()
54107
{
55108
return Provider.DisposeAsync();

0 commit comments

Comments
 (0)