diff --git a/PolyPilot.Tests/BridgePromptQueueTests.cs b/PolyPilot.Tests/BridgePromptQueueTests.cs index 1f9e760587..14f3e843c2 100644 --- a/PolyPilot.Tests/BridgePromptQueueTests.cs +++ b/PolyPilot.Tests/BridgePromptQueueTests.cs @@ -27,13 +27,15 @@ private static int GetFreePort() return port; } - private static async Task WaitForAsync(Func condition, CancellationToken ct, int pollMs = 50, int maxMs = 8000) + private static async Task WaitForAsync(Func condition, CancellationToken ct, int pollMs = 50, int maxMs = 15000) { var sw = System.Diagnostics.Stopwatch.StartNew(); - while (!condition() && sw.ElapsedMilliseconds < maxMs) + while (!condition() && sw.ElapsedMilliseconds < maxMs && !ct.IsCancellationRequested) await Task.Delay(pollMs, ct); - if (!condition()) + // Check condition first so we get a descriptive TimeoutException instead of bare OCE + if (!condition() && !ct.IsCancellationRequested) throw new TimeoutException($"WaitForAsync condition not met within {maxMs}ms"); + ct.ThrowIfCancellationRequested(); } public BridgePromptQueueTests() diff --git a/PolyPilot.Tests/ScenarioReferenceTests.cs b/PolyPilot.Tests/ScenarioReferenceTests.cs index cf596df408..e31a3ba83f 100644 --- a/PolyPilot.Tests/ScenarioReferenceTests.cs +++ b/PolyPilot.Tests/ScenarioReferenceTests.cs @@ -245,6 +245,16 @@ public void Scenario_ScheduledTaskCreateAndRunNow_HasUnitTestCoverage() Assert.True(true, "See ScheduledTaskTests.Service_EvaluateTasksAsync_ExecutesDueTasks and Service_ExecuteTask_NewSession_RecordsCompletionAndGeneratedSessionName"); } + /// + /// Scenario: "scheduled-task-desktop-entrypoint" + /// Structural coverage: the desktop sidebar keeps the Scheduled Tasks overflow link wired to /scheduled-tasks. + /// + [Fact] + public void Scenario_ScheduledTaskDesktopEntrypoint_HasMarkupCoverage() + { + Assert.True(true, "See SessionSidebar.razor: href=\"/scheduled-tasks\" confirmed in NavigateHeaderOverflow routing"); + } + /// /// Scenario: "scheduled-task-run-now-twice-uses-unique-session" /// Unit test equivalents: Service_ExecuteTask_NewSession_ReusesTimestampButGeneratesUniqueName @@ -265,6 +275,32 @@ public void Scenario_ScheduledTaskDisableEditPreservesToggle_HasUnitTestCoverage Assert.True(true, "See ScheduledTaskTests.Service_UpdateTask_DoesNotOverwriteIsEnabled_FromStaleEditSnapshot"); } + /// + /// Scenario: "scheduled-task-target-existing-session" + /// Unit test equivalents: Service_EvaluateTasksAsync_ExecutesDueTasks, + /// Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession + /// + [Fact] + public void Scenario_ScheduledTaskTargetExistingSession_HasUnitTestCoverage() + { + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_EvaluateTasksAsync_ExecutesDueTasks))); + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession))); + } + + /// + /// Scenario: "scheduled-task-slash-command-current-session" + /// Unit test equivalents: TryCreateFromSlashCommand_ValidInput_CreatesTask, + /// TryCreateFromSlashCommand_EveryPrefix_IsSupported, + /// and SlashCommandAutocompleteTests.AutocompleteCommands_MatchHandlerCommands + /// + [Fact] + public void Scenario_ScheduledTaskSlashCommand_HasUnitTestCoverage() + { + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.TryCreateFromSlashCommand_ValidInput_CreatesTask))); + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.TryCreateFromSlashCommand_EveryPrefix_IsSupported))); + Assert.NotNull(typeof(SlashCommandAutocompleteTests).GetMethod(nameof(SlashCommandAutocompleteTests.AutocompleteCommands_MatchHandlerCommands))); + } + /// /// Scenario: "scheduled-task-form-validation" /// Unit test equivalents: CronExpression_ValidatesExpectedInputs, @@ -276,6 +312,26 @@ public void Scenario_ScheduledTaskValidation_HasUnitTestCoverage() Assert.True(true, "See ScheduledTaskTests cron and time validation tests"); } + /// + /// Scenario: "scheduled-task-persists-after-relaunch" + /// Unit test equivalent: Service_SaveAndLoad_RoundTrips + /// + [Fact] + public void Scenario_ScheduledTaskPersistsAfterRelaunch_HasUnitTestCoverage() + { + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.Service_SaveAndLoad_RoundTrips))); + } + + /// + /// Scenario: "scheduled-task-target-session-close-disables-task" + /// Unit test equivalent: CloseSessionAsync_DisablesScheduledTasksTargetingDeletedSession + /// + [Fact] + public void Scenario_ScheduledTaskCloseSessionDisablesTask_HasUnitTestCoverage() + { + Assert.NotNull(typeof(ScheduledTaskTests).GetMethod(nameof(ScheduledTaskTests.CloseSessionAsync_DisablesScheduledTasksTargetingDeletedSession))); + } + /// /// Scenario: "vscode-remote-tunnels-in-remote-mode" /// Unit test equivalents: PlatformHelperTests.BuildVSCodeRemoteArg_*, diff --git a/PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json b/PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json index fb4ba54a56..10d9dcf7a1 100644 --- a/PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json +++ b/PolyPilot.Tests/Scenarios/scheduled-task-scenarios.json @@ -6,6 +6,28 @@ "initialRoute": "/scheduled-tasks" }, "scenarios": [ + { + "id": "scheduled-task-desktop-entrypoint", + "name": "Open Scheduled Tasks from the desktop overflow menu", + "steps": [ + { "action": "navigate", "route": "/" }, + { "action": "wait", "ms": 500 }, + { "action": "click", "selector": ".header-overflow-trigger" }, + { "action": "wait", "ms": 300 }, + { + "action": "evaluate", + "script": "({ hasLink: !!document.querySelector(\"a[href='/scheduled-tasks']\"), title: document.querySelector(\"a[href='/scheduled-tasks'] .header-overflow-action-title\")?.textContent?.trim() ?? '' })", + "expect": { "contains": "\"hasLink\":true" } + }, + { "action": "click", "selector": "a[href='/scheduled-tasks']" }, + { "action": "wait", "ms": 1000 }, + { + "action": "evaluate", + "script": "({ path: window.location.pathname, hasPage: !!document.querySelector('#scheduled-tasks-page') })", + "expect": { "contains": "\"hasPage\":true" } + } + ] + }, { "id": "scheduled-task-create-and-run-now", "name": "Create an interval task and run it immediately", @@ -52,6 +74,42 @@ } ] }, + { + "id": "scheduled-task-target-existing-session", + "name": "Create a scheduled task that runs inside an existing session", + "steps": [ + { "action": "note", "text": "Create a normal chat session named 'Scheduled Target Session' before running this scenario." }, + { "action": "click", "selector": "#scheduled-task-new" }, + { "action": "wait", "ms": 500 }, + { "action": "type", "selector": "#scheduled-task-name", "text": "Existing Session Task" }, + { "action": "type", "selector": "#scheduled-task-prompt", "text": "Reply inside the existing scheduled target session." }, + { + "action": "evaluate", + "script": "const sel = document.querySelector('#scheduled-task-session'); sel.value = 'Scheduled Target Session'; sel.dispatchEvent(new Event('change', { bubbles: true })); sel.value;" + }, + { "action": "click", "selector": "#scheduled-task-save" }, + { "action": "wait", "ms": 1000 }, + { + "action": "evaluate", + "script": "document.querySelector('.task-card[data-task-name=\"Existing Session Task\"] .task-tag')?.textContent?.trim()", + "expect": { "contains": "Session: Scheduled Target Session" } + } + ] + }, + { + "id": "scheduled-task-slash-command-current-session", + "name": "Create a scheduled task from chat using /schedule", + "steps": [ + { "action": "note", "text": "Open any active chat session and send `/schedule 30m Ask me for a status update.` from the message box." }, + { "action": "navigate", "route": "/scheduled-tasks" }, + { "action": "wait", "ms": 1000 }, + { + "action": "evaluate", + "script": "const card = [...document.querySelectorAll('.task-card')].find(x => (x.textContent ?? '').includes('Ask me for a status update.')); JSON.stringify({ exists: !!card, text: card?.textContent ?? '' });", + "expect": { "contains": "\"exists\":true" } + } + ] + }, { "id": "scheduled-task-disable-edit-preserves-toggle", "name": "Disable a task, edit it, and verify it stays disabled", @@ -95,6 +153,35 @@ "expect": { "contains": "Invalid cron expression" } } ] + }, + { + "id": "scheduled-task-persists-after-relaunch", + "name": "Scheduled tasks remain visible after the app restarts", + "steps": [ + { "action": "restartApp" }, + { "action": "waitForAgent" }, + { "action": "navigate", "route": "/scheduled-tasks" }, + { "action": "wait", "ms": 1000 }, + { + "action": "evaluate", + "script": "[...document.querySelectorAll('.task-card .task-name')].map(x => x.textContent?.trim()).filter(Boolean)", + "expect": { "contains": "Integration Task Edited" } + } + ] + }, + { + "id": "scheduled-task-target-session-close-disables-task", + "name": "Closing the target session disables the linked scheduled task", + "steps": [ + { "action": "note", "text": "Close the 'Scheduled Target Session' chat from the main session list, then return to /scheduled-tasks." }, + { "action": "navigate", "route": "/scheduled-tasks" }, + { "action": "wait", "ms": 1000 }, + { + "action": "evaluate", + "script": "const card = document.querySelector('.task-card[data-task-name=\"Existing Session Task\"]'); ({ exists: !!card, disabled: card?.classList.contains('disabled') ?? false });", + "expect": { "contains": "\"disabled\":true" } + } + ] } ] } diff --git a/PolyPilot.Tests/ScheduledTaskTests.cs b/PolyPilot.Tests/ScheduledTaskTests.cs index b807ad3825..b4f6fa0390 100644 --- a/PolyPilot.Tests/ScheduledTaskTests.cs +++ b/PolyPilot.Tests/ScheduledTaskTests.cs @@ -129,6 +129,74 @@ public void ScheduledTask_JsonRoundTrip_List() Assert.Equal(new List { 1, 3, 5 }, deserialized[1].DaysOfWeek); } + [Theory] + [InlineData("30m Check CI health", ScheduleType.Interval, 30)] + [InlineData("2h Summarize new issues", ScheduleType.Interval, 120)] + [InlineData("daily Draft my standup", ScheduleType.Daily, 24 * 60)] + [InlineData("weekly Review the roadmap", ScheduleType.Weekly, 7 * 24 * 60)] + public void TryCreateFromSlashCommand_ValidInput_CreatesTask(string input, ScheduleType expectedSchedule, int expectedMinutes) + { + var created = ScheduledTask.TryCreateFromSlashCommand( + input, + "Current Session", + "/tmp/repo", + DateTime.UtcNow, + out var task, + out var error); + + Assert.True(created, error); + Assert.NotNull(task); + Assert.Equal("Current Session", task!.SessionName); + Assert.Equal("/tmp/repo", task.WorkingDirectory); + Assert.Equal(expectedSchedule, task.Schedule); + Assert.Equal(expectedMinutes, task.IntervalMinutes); + Assert.True(task.IsEnabled); + Assert.NotEmpty(task.Name); + Assert.NotEmpty(task.Prompt); + } + + [Fact] + public void TryCreateFromSlashCommand_EveryPrefix_IsSupported() + { + var created = ScheduledTask.TryCreateFromSlashCommand( + "every 2 hours Send me a recap", + "Current Session", + null, + DateTime.UtcNow, + out var task, + out var error); + + Assert.True(created, error); + Assert.NotNull(task); + Assert.Equal(ScheduleType.Interval, task!.Schedule); + Assert.Equal(120, task.IntervalMinutes); + Assert.Equal("Send me a recap", task.Prompt); + } + + [Fact] + public void TryCreateFromSlashCommand_InvalidInput_ReturnsUsage() + { + var created = ScheduledTask.TryCreateFromSlashCommand( + "someday maybe remind me", + "Current Session", + null, + DateTime.UtcNow, + out var task, + out var error); + + Assert.False(created); + Assert.Null(task); + Assert.Contains("Usage: `/schedule", error); + } + + [Fact] + public void GetSlashCommandUsage_IncludesDeleteSubcommand() + { + var usage = ScheduledTask.GetSlashCommandUsage(); + Assert.Contains("/schedule delete", usage); + Assert.Contains("/schedule list", usage); + } + // ── Schedule description ──────────────────────────────────── [Fact] @@ -145,6 +213,27 @@ public void ScheduleDescription_Interval_Singular() Assert.Equal("Every 1 minute", task.ScheduleDescription); } + [Fact] + public void ScheduleDescription_Interval_Hours() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 120 }; + Assert.Equal("Every 2 hours", task.ScheduleDescription); + } + + [Fact] + public void ScheduleDescription_Interval_Days() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 2 * 24 * 60 }; + Assert.Equal("Every 2 days", task.ScheduleDescription); + } + + [Fact] + public void ScheduleDescription_Interval_Weeks() + { + var task = new ScheduledTask { Schedule = ScheduleType.Interval, IntervalMinutes = 7 * 24 * 60 }; + Assert.Equal("Every week", task.ScheduleDescription); + } + [Fact] public void ScheduleDescription_Daily() { @@ -729,6 +818,60 @@ public async Task Service_ExecuteTask_UsesCopilotUiDispatcherCapturedAfterConstr } } + [Fact] + public async Task Service_ExecuteTask_ExistingSession_UsesThatSessionWithoutCreatingAnotherSession() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"polypilot-sched-test-{Guid.NewGuid():N}.json"); + var previousPath = ScheduledTaskService.GetTasksFilePathForTesting(); + ScheduledTaskService.SetTasksFilePathForTesting(tempFile); + + ScheduledTaskService? svc = null; + try + { + var copilot = CreateCopilotService(); + svc = new ScheduledTaskService(copilot); + await copilot.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await copilot.CreateSessionAsync("existing-session"); + + var task = new ScheduledTask + { + Name = "Existing Session Run", + Prompt = "Reply in the existing session", + SessionName = "existing-session", + Schedule = ScheduleType.Interval, + IntervalMinutes = 1, + IsEnabled = true + }; + svc.AddTask(task); + + await svc.ExecuteTaskAsync(task, DateTime.UtcNow); + + var updated = svc.GetTask(task.Id); + Assert.NotNull(updated); + var run = Assert.Single(updated!.RecentRuns); + Assert.True(run.Success); + Assert.Equal("existing-session", run.SessionName); + + var sessions = copilot.GetAllSessions().ToList(); + Assert.Single(sessions); + Assert.Equal("existing-session", sessions[0].Name); + + var session = sessions[0]; + lock (session.HistoryLock) + { + Assert.Contains(session.History, m => m.Role == "user" && + m.Content.Contains("Reply in the existing session", StringComparison.Ordinal)); + Assert.Contains(session.History, m => m.Role == "assistant"); + } + } + finally + { + svc?.Dispose(); + try { File.Delete(tempFile); } catch { } + ScheduledTaskService.SetTasksFilePathForTesting(previousPath ?? tempFile); + } + } + [Fact] public async Task Service_EvaluateTasksAsync_DoesNotBlockOtherDueTasksBehindLongRun() { diff --git a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs index a28ca692c6..54be4ad3a2 100644 --- a/PolyPilot.Tests/SlashCommandAutocompleteTests.cs +++ b/PolyPilot.Tests/SlashCommandAutocompleteTests.cs @@ -119,7 +119,7 @@ public void AutocompleteList_HasExpectedMinimumCommands() var commands = GetAutocompleteCommands(); var expected = new[] { "/help", "/clear", "/compact", "/new", "/sessions", "/rename", "/version", "/diff", "/status", "/mcp", - "/plugin", "/fleet", "/usage" }; + "/plugin", "/fleet", "/schedule", "/usage" }; foreach (var cmd in expected) { @@ -144,7 +144,7 @@ public void ParameterlessCommands_MarkedForAutoSend() } // Commands with args should have hasArgs: true - var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/status", "/agent" }; + var withArgs = new[] { "/new", "/rename", "/diff", "/fleet", "/mcp", "/plugin", "/prompt", "/schedule", "/status", "/agent" }; foreach (var cmd in withArgs) { var pattern = $"cmd: '{cmd}',"; diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 66e8d2fdbd..2be50cd82e 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -97,22 +97,32 @@ else
-
+
-
+ @if (isHeaderOverflowOpen) + { + + } +
+ @onclick='() => NavigateHeaderOverflow("/tutorial")' + @onclick:preventDefault="true"> @@ -124,7 +134,8 @@ else + @onclick='() => NavigateHeaderOverflow("/scheduled-tasks")' + @onclick:preventDefault="true"> @@ -136,7 +147,7 @@ else
public class ScheduledTask { + private static readonly Regex SlashCommandPattern = new( + @"^(?:every\s+)?(?(?:\d+\s*(?:m|min(?:ute)?s?|h|hr|hrs|hour|hours|d|day|days|w|week|weeks))|hourly|daily|weekly)\s+(?.+)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex SlashFrequencyPattern = new( + @"^(?\d+)\s*(?m|min(?:ute)?s?|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Name { get; set; } = ""; public string Prompt { get; set; } = ""; @@ -86,6 +95,62 @@ public class ScheduledTask public static bool IsValidTimeOfDay(string? time) => !string.IsNullOrEmpty(time) && TimeSpan.TryParse(time, out var ts) && ts.TotalHours >= 0 && ts.TotalHours < 24; + public static string GetSlashCommandUsage() => + "Usage: `/schedule `\n" + + "Examples:\n" + + "- `/schedule 30m Check the build status`\n" + + "- `/schedule every 2h Summarize recent changes`\n" + + "- `/schedule daily Draft my standup update`\n" + + "- `/schedule list` — show tasks targeting this session\n" + + "- `/schedule delete <#>` — delete a task by index from the list"; + + public static bool TryCreateFromSlashCommand( + string rawArgs, + string currentSessionName, + string? workingDirectory, + DateTime nowUtc, + out ScheduledTask? task, + out string error) + { + task = null; + error = GetSlashCommandUsage(); + + if (string.IsNullOrWhiteSpace(rawArgs)) + return false; + + var match = SlashCommandPattern.Match(rawArgs.Trim()); + if (!match.Success) + return false; + + var frequencyToken = match.Groups["frequency"].Value.Trim(); + var prompt = match.Groups["prompt"].Value.Trim(); + if (string.IsNullOrWhiteSpace(prompt)) + return false; + + if (!TryParseSlashFrequency(frequencyToken, nowUtc, out var schedule, out var intervalMinutes, out var timeOfDay, out var daysOfWeek)) + { + error = $"Unsupported frequency `{frequencyToken}`.\n\n{GetSlashCommandUsage()}"; + return false; + } + + task = new ScheduledTask + { + Name = BuildSuggestedTaskName(prompt), + Prompt = prompt, + SessionName = currentSessionName, + WorkingDirectory = workingDirectory, + Schedule = schedule, + IntervalMinutes = intervalMinutes, + TimeOfDay = timeOfDay, + DaysOfWeek = daysOfWeek, + IsEnabled = true, + CreatedAt = nowUtc + }; + + error = string.Empty; + return true; + } + // ── Schedule calculation ────────────────────────────────────────── /// @@ -251,7 +316,7 @@ public string ScheduleDescription { return Schedule switch { - ScheduleType.Interval => $"Every {IntervalMinutes} minute{(IntervalMinutes != 1 ? "s" : "")}", + ScheduleType.Interval => FormatInterval(), ScheduleType.Daily => $"Daily at {TimeOfDay}", ScheduleType.Weekly => $"Weekly ({FormatDays()}) at {TimeOfDay}", ScheduleType.Cron => $"Cron: {CronExpression ?? "(not set)"}", @@ -260,6 +325,27 @@ public string ScheduleDescription } } + private string FormatInterval() + { + if (IntervalMinutes <= 0) return "Every 0 minutes"; + if (IntervalMinutes % (7 * 24 * 60) == 0) + { + var weeks = IntervalMinutes / (7 * 24 * 60); + return weeks == 1 ? "Every week" : $"Every {weeks} weeks"; + } + if (IntervalMinutes % (24 * 60) == 0) + { + var days = IntervalMinutes / (24 * 60); + return days == 1 ? "Every day" : $"Every {days} days"; + } + if (IntervalMinutes % 60 == 0) + { + var hours = IntervalMinutes / 60; + return hours == 1 ? "Every hour" : $"Every {hours} hours"; + } + return $"Every {IntervalMinutes} minute{(IntervalMinutes != 1 ? "s" : "")}"; + } + private string FormatDays() { var dayNames = new[] { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; @@ -267,6 +353,91 @@ private string FormatDays() return string.Join(", ", sorted.Select(d => dayNames[d])); } + private static bool TryParseSlashFrequency( + string frequencyToken, + DateTime nowUtc, + out ScheduleType schedule, + out int intervalMinutes, + out string timeOfDay, + out List daysOfWeek) + { + var localNow = nowUtc.ToLocalTime(); + schedule = ScheduleType.Interval; + intervalMinutes = 60; + timeOfDay = $"{localNow.Hour:D2}:{localNow.Minute:D2}"; + daysOfWeek = new List { (int)localNow.DayOfWeek }; + + switch (frequencyToken.Trim().ToLowerInvariant()) + { + case "hourly": + intervalMinutes = 60; + return true; + case "daily": + schedule = ScheduleType.Daily; + intervalMinutes = 24 * 60; + return true; + case "weekly": + schedule = ScheduleType.Weekly; + intervalMinutes = 7 * 24 * 60; + return true; + } + + var match = SlashFrequencyPattern.Match(frequencyToken.Trim()); + if (!match.Success || !int.TryParse(match.Groups["value"].Value, out var value) || value <= 0) + return false; + + try + { + var unit = match.Groups["unit"].Value.ToLowerInvariant(); + switch (unit[0]) + { + case 'm': + intervalMinutes = value; + return true; + case 'h': + intervalMinutes = checked(value * 60); + return true; + case 'd': + if (value == 1) + { + schedule = ScheduleType.Daily; + intervalMinutes = 24 * 60; + } + else + { + intervalMinutes = checked(value * 24 * 60); + } + return true; + case 'w': + if (value == 1) + { + schedule = ScheduleType.Weekly; + intervalMinutes = 7 * 24 * 60; + } + else + { + intervalMinutes = checked(value * 7 * 24 * 60); + } + return true; + default: + return false; + } + } + catch (OverflowException) + { + return false; + } + } + + private static string BuildSuggestedTaskName(string prompt) + { + var singleLine = Regex.Replace(prompt, @"\s+", " ").Trim(); + if (singleLine.Length <= 60) + return singleLine; + + return singleLine[..57] + "..."; + } + // ── Cron expression support ────────────────────────────────────── /// diff --git a/PolyPilot/Services/DemoService.cs b/PolyPilot/Services/DemoService.cs index 7958f5b73e..2df30ab1ce 100644 --- a/PolyPilot/Services/DemoService.cs +++ b/PolyPilot/Services/DemoService.cs @@ -94,8 +94,6 @@ public async Task SimulateResponseAsync(string sessionName, string userPrompt, S } session.IsProcessing = false; - session.MessageCount = session.History.Count; - Post(syncContext, () => OnTurnEnd?.Invoke(sessionName)); Post(syncContext, () => OnStateChanged?.Invoke()); } diff --git a/PolyPilot/Services/ScheduledTaskService.cs b/PolyPilot/Services/ScheduledTaskService.cs index be15a05d14..0810509eba 100644 --- a/PolyPilot/Services/ScheduledTaskService.cs +++ b/PolyPilot/Services/ScheduledTaskService.cs @@ -15,6 +15,7 @@ public class ScheduledTaskService : IDisposable /// Override file path for tests to prevent writing to real ~/.polypilot/. internal static void SetTasksFilePathForTesting(string path) => _tasksFilePath = path; + internal static string? GetTasksFilePathForTesting() => _tasksFilePath; private readonly CopilotService _copilotService; private readonly List _tasks = new(); diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 09dd929466..c96a1b5071 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -309,17 +309,39 @@ internal void EnqueuePendingPromptForTesting(string sessionName, string message, /// Called by CopilotService after IsRestoring transitions to false. /// Serialized via _drainLock to prevent concurrent drains from reordering prompts. /// - public async Task DrainPendingPromptsAsync() + public async Task DrainPendingPromptsAsync(CancellationToken ct = default) { - await _drainLock.WaitAsync(); + await _drainLock.WaitAsync(ct); try { while (_pendingBridgePrompts.TryDequeue(out var pending)) { + if (ct.IsCancellationRequested) + { + // Re-enqueue before throwing so the prompt survives cancellation + _pendingBridgePrompts.Enqueue(pending); + ct.ThrowIfCancellationRequested(); + } + BridgeLog($"[BRIDGE] Replaying queued prompt for '{pending.SessionName}'"); + var dispatched = false; try { - await DispatchBridgePromptAsync(pending.SessionName, pending.Message, pending.AgentMode); + await DispatchBridgePromptAsync(pending.SessionName, pending.Message, pending.AgentMode, ct: ct); + dispatched = true; + if (!await WaitForBridgeSendToStartAsync(pending.SessionName, ct)) + { + BridgeLog($"[BRIDGE] Send confirmation timed out for '{pending.SessionName}' — continuing drain"); + } + } + catch (OperationCanceledException) + { + // Only re-enqueue if dispatch didn't complete — otherwise the prompt + // was already accepted by SendPromptAsync and re-enqueueing would + // cause duplicate delivery on the next drain. + if (!dispatched) + _pendingBridgePrompts.Enqueue(pending); + throw; } catch (Exception ex) { @@ -333,6 +355,26 @@ public async Task DrainPendingPromptsAsync() } } + /// + /// Best-effort poll for IsProcessing confirmation after dispatch. + /// 200ms ceiling (20×10ms) — may time out on slow devices or under memory pressure. + /// Timeout is non-fatal: the drain logs a warning and continues. The SessionBusyException + /// guard in DispatchBridgePromptAsync is the real safety net for ordering. + /// + private async Task WaitForBridgeSendToStartAsync(string sessionName, CancellationToken ct = default) + { + for (var attempt = 0; attempt < 20; attempt++) + { + if (_copilot?.GetSession(sessionName)?.IsProcessing == true) + return true; + + await Task.Delay(10, ct).ConfigureAwait(false); + } + + BridgeLog($"[BRIDGE] WaitForBridgeSendToStartAsync timed out for '{sessionName}' — IsProcessing not confirmed within 200ms"); + return false; + } + /// /// Dispatch a bridge prompt with orchestrator routing on the UI thread. /// Shared by both the live send_message handler and the drain replay loop. @@ -354,6 +396,10 @@ private async Task DispatchBridgePromptAsync(string sessionName, string message, } else { + var info = _copilot.GetSession(sessionName); + if (info?.IsProcessing == true) + throw new SessionBusyException(sessionName); + await _copilot.SendPromptAsync(sessionName, message, imagePaths, cancellationToken: ct, agentMode: agentMode); } }); diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 2b14de7e2c..4157554761 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -867,6 +867,7 @@ { cmd: '/plugin', usage: '[enable|disable] [plugin-name]', desc: 'Manage installed plugins', hasArgs: true }, { cmd: '/prompt', usage: 'use [-- context] | save | edit | delete', desc: 'Manage saved prompts', hasArgs: true }, { cmd: '/rename', usage: '', desc: 'Rename current session', hasArgs: true }, + { cmd: '/schedule', usage: ' | list', desc: 'Schedule a recurring prompt in this session', hasArgs: true }, { cmd: '/sessions', desc: 'List all sessions', hasArgs: false }, { cmd: '/status', usage: '[path] [--short]', desc: 'Show git status', hasArgs: true }, { cmd: '/usage', desc: 'Show token usage and quota', hasArgs: false },