Skip to content

Commit b4b2153

Browse files
sharpninjaCopilot
andcommitted
feat: voice session hardening - client reconnect and heartbeat
- Client attempts to find existing session by DeviceId before creating new - Add heartbeat timer (5 min) to keep server session alive during idle periods - Dispose heartbeat timer on session end - Add TurnCounter and TranscriptCount to McpVoiceSessionStatus - Add FindExistingSessionAsync to McpVoiceConversationService Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 15a1bca commit b4b2153

5 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/McpServerManager.Android/Views/SimplifiedVoiceView.axaml.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public partial class SimplifiedVoiceView : UserControl
8383
private bool _sendRequested;
8484
private string? _manualText;
8585
private bool _foregroundServiceRunning;
86+
private Timer? _heartbeatTimer;
8687
private TextBox? _textInputBox;
8788
private Button? _pauseButton;
8889
private Button? _stopButton;
@@ -219,6 +220,9 @@ private async Task StartSessionAsync()
219220
// Start foreground service to keep voice alive in background
220221
StartForegroundService("Voice session active. Listening...");
221222

223+
// Start heartbeat to keep session alive during idle periods
224+
_heartbeatTimer = new Timer(OnHeartbeatTick, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
225+
222226
// Announce readiness and start listening
223227
PlayChime();
224228

@@ -234,6 +238,8 @@ private async Task StartSessionAsync()
234238
}
235239
finally
236240
{
241+
_heartbeatTimer?.Dispose();
242+
_heartbeatTimer = null;
237243
_conversationActive = false;
238244
_isPaused = false;
239245
_isSpeaking = false;
@@ -757,6 +763,25 @@ private void SubmitTypedText()
757763
_textInputBox.Text = string.Empty;
758764
}
759765

766+
private void OnHeartbeatTick(object? state)
767+
{
768+
var vm = VM;
769+
if (vm == null || !_sessionReady || string.IsNullOrWhiteSpace(vm.SessionId)) return;
770+
771+
_ = Task.Run(async () =>
772+
{
773+
try
774+
{
775+
await vm.RefreshStatusCommand.ExecuteAsync(null).ConfigureAwait(false);
776+
_logger.LogDebug("Heartbeat OK for session {SessionId}", vm.SessionId);
777+
}
778+
catch (Exception ex)
779+
{
780+
_logger.LogWarning(ex, "Heartbeat failed for session {SessionId}", vm.SessionId);
781+
}
782+
});
783+
}
784+
760785
private void UpdateButtons()
761786
{
762787
Dispatcher.UIThread.Post(() =>

src/McpServerManager.Core/Models/McpVoiceConversationContracts.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ public sealed record McpVoiceSessionStatus
121121

122122
[JsonPropertyName("lastTurnId")]
123123
public string? LastTurnId { get; init; }
124+
125+
[JsonPropertyName("turnCounter")]
126+
public int TurnCounter { get; init; }
127+
128+
[JsonPropertyName("transcriptCount")]
129+
public int TranscriptCount { get; init; }
124130
}
125131

126132
/// <summary>Transcript response payload for a voice session.</summary>

src/McpServerManager.Core/Services/McpVoiceConversationService.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,22 @@ public async Task<McpVoiceTranscriptResponse> GetTranscriptAsync(string sessionI
226226
?? throw new InvalidOperationException("MCP voice transcript returned an empty response.");
227227
}
228228

229+
/// <summary>
230+
/// Finds an active voice session for the specified device.
231+
/// Returns <c>null</c> if no active session exists.
232+
/// </summary>
233+
public async Task<McpVoiceSessionStatus?> FindExistingSessionAsync(string deviceId, CancellationToken cancellationToken = default)
234+
{
235+
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
236+
237+
using var client = await CreateAuthorizedClientAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(true);
238+
using var response = await client.GetAsync($"mcpserver/voice/session?deviceId={Uri.EscapeDataString(deviceId)}", cancellationToken).ConfigureAwait(true);
239+
if (response.StatusCode == HttpStatusCode.NotFound)
240+
return null;
241+
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(true);
242+
return await response.Content.ReadFromJsonAsync<McpVoiceSessionStatus>(JsonOptions, cancellationToken).ConfigureAwait(true);
243+
}
244+
229245
/// <summary>
230246
/// Deletes a voice session and any associated in-memory state.
231247
/// </summary>

src/McpServerManager.Core/ViewModels/VoiceConversationViewModel.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,40 @@ private async Task CreateSessionAsync()
9191
IsBusy = true;
9292
try
9393
{
94+
var deviceId = Environment.MachineName;
95+
96+
// Try to reconnect to an existing session for this device
97+
GlobalStatusChanged?.Invoke("Looking for existing voice session...");
98+
StatusText = "Looking for existing voice session...";
99+
try
100+
{
101+
var existing = await _voiceService.FindExistingSessionAsync(deviceId).ConfigureAwait(true);
102+
if (existing is not null)
103+
{
104+
SessionId = existing.SessionId;
105+
Language = string.IsNullOrWhiteSpace(existing.Language) ? Language : existing.Language;
106+
IsSessionActive = true;
107+
LastTurnId = existing.LastTurnId ?? string.Empty;
108+
StatusText = $"Resumed session {SessionId} (turn {existing.TurnCounter})";
109+
GlobalStatusChanged?.Invoke(StatusText);
110+
_logger.LogInformation("Resumed existing voice session {SessionId} (turns={TurnCounter}, transcripts={TranscriptCount})",
111+
SessionId, existing.TurnCounter, existing.TranscriptCount);
112+
return;
113+
}
114+
}
115+
catch (Exception ex)
116+
{
117+
_logger.LogWarning(ex, "Device session lookup failed, falling back to create");
118+
}
119+
120+
// No existing session — create a new one
94121
GlobalStatusChanged?.Invoke("Creating voice session...");
95122
StatusText = "Creating voice session...";
96123
var response = await _voiceService.CreateSessionAsync(new McpVoiceSessionCreateRequest
97124
{
98125
Language = Language,
99126
ClientName = "RequestTracker.Android",
100-
DeviceId = Environment.MachineName
127+
DeviceId = deviceId
101128
}).ConfigureAwait(true);
102129

103130
SessionId = response.SessionId;

0 commit comments

Comments
 (0)