diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index d0390c226..fb356c91b 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -59,6 +59,15 @@ public interface IOperatorGatewayClient // ─── Request Methods ─── Task SendChatMessageAsync(string message, string? sessionKey = null); Task SendChatMessageForRunAsync(string message, string? sessionKey = null); + /// + /// Fetches the normalized conversation transcript for a session + /// (chat.history). Ships with a default so adding it does not + /// source-break external implementers (test doubles); the real client + /// overrides it. Non-overriding clients fail explicitly instead of looking + /// like an empty transcript. + /// + Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) + => Task.FromException(new NotSupportedException("chat.history is not supported by this gateway client.")); Task CheckHealthAsync(); Task RequestSessionsAsync(string? agentId = null); Task RequestUsageAsync(); diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs index a63b319ee..f30e76d5b 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs @@ -434,8 +434,7 @@ internal static SessionCompactionCheckpointList ParseCompactionCheckpointList(Js { if (item.ValueKind != JsonValueKind.Object) continue; var checkpoint = ParseCompactionCheckpoint(item); - if (!string.IsNullOrEmpty(checkpoint.Id)) - checkpoints.Add(checkpoint); + checkpoints.Add(checkpoint); } } diff --git a/src/OpenClaw.Shared/Sessions/SessionActionPlanner.cs b/src/OpenClaw.Shared/Sessions/SessionActionPlanner.cs new file mode 100644 index 000000000..de32659fc --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionActionPlanner.cs @@ -0,0 +1,190 @@ +namespace OpenClaw.Shared.Sessions; + +/// +/// The set of per-session lifecycle actions the UI can offer: open, reset +/// (new chat), compact, delete, export, plus compaction-checkpoint +/// branch/restore. +/// +public enum SessionActionKind +{ + OpenChat, + Reset, + Compact, + Delete, + Export, + Branch, + Restore, +} + +/// +/// The copy a confirmation dialog should display for a destructive or +/// context-altering session action. +/// +public sealed record SessionActionPrompt( + SessionActionKind Kind, + string SessionName, + string Title, + string Body, + string ConfirmLabel, + bool IsDestructive); + +public enum SessionMainState +{ + Main, + NotMain, + Unknown, +} + +/// +/// Pure decision logic for session lifecycle actions, shared by the tray's +/// session menus (SessionsPage flyout + App tray/toast handlers) so every +/// entry point applies the same confirmation copy and main-session +/// protection. Contains no UI or gateway dependencies so it is unit testable. +/// +public static class SessionActionPlanner +{ + /// + /// Destructive actions clear or remove conversation state and must always + /// be confirmed and styled as dangerous. + /// + public static bool IsDestructive(SessionActionKind kind) => + kind is SessionActionKind.Reset + or SessionActionKind.Delete + or SessionActionKind.Restore; + + /// + /// Actions that prompt before running. Compact is reversible-ish (it + /// archives rather than deletes) but still alters the live context, so it + /// is confirmed too. Restore rolls the live session back to a checkpoint + /// and is therefore confirmed. + /// + public static bool RequiresConfirmation(SessionActionKind kind) => + kind is SessionActionKind.Reset + or SessionActionKind.Compact + or SessionActionKind.Delete + or SessionActionKind.Restore; + + /// + /// Returns false when an action must not be offered for the given session. + /// The main session is the gateway's primary conversation: it cannot be + /// deleted and is not eligible for a destructive checkpoint restore. + /// Resetting, compacting, and branching it are allowed. + /// + public static bool IsAllowed(SessionActionKind kind, bool isMainSession, out string? blockedReason) + { + if (isMainSession && kind == SessionActionKind.Delete) + { + blockedReason = "The main session can't be deleted. Reset it instead to start fresh."; + return false; + } + + if (isMainSession && kind == SessionActionKind.Restore) + { + blockedReason = "The main session can't be rolled back to a checkpoint. Branch from it instead."; + return false; + } + + blockedReason = null; + return true; + } + + public static bool IsAllowed(SessionActionKind kind, SessionMainState mainState, out string? blockedReason) + { + if (!IsAllowed(kind, mainState == SessionMainState.Main, out blockedReason)) + return false; + + if (mainState == SessionMainState.Unknown && + kind is SessionActionKind.Delete or SessionActionKind.Restore) + { + blockedReason = "Session identity is still loading. Try again after sessions refresh."; + return false; + } + + return true; + } + + public static SessionMainState ResolveMainState( + string key, + bool? rowIsMain = null, + string? mainSessionKey = null, + IEnumerable? sessions = null) + { + if (IsMainSessionKeyShape(key)) return SessionMainState.Main; + if (rowIsMain == true) return SessionMainState.Main; + + if (string.Equals(mainSessionKey, key, StringComparison.Ordinal)) return SessionMainState.Main; + if (!string.IsNullOrWhiteSpace(mainSessionKey)) return SessionMainState.NotMain; + + var session = sessions?.FirstOrDefault(s => string.Equals(s.Key, key, StringComparison.Ordinal)); + if (session?.IsMain == true) return SessionMainState.Main; + if (session is not null || rowIsMain.HasValue) return SessionMainState.NotMain; + + return SessionMainState.Unknown; + } + + public static bool IsMainSessionKeyShape(string key) + { + if (string.Equals(key, "main", StringComparison.Ordinal)) return true; + return key.EndsWith(":main", StringComparison.Ordinal) || + key.Contains(":main:main", StringComparison.Ordinal); + } + + /// + /// Builds the confirmation copy for an action, or null when the + /// action needs no confirmation. The session's friendly name is used when + /// available, falling back to the raw key. + /// + public static SessionActionPrompt? BuildPrompt( + SessionActionKind kind, + string sessionKey, + string? displayName, + bool isMainSession) + { + if (!RequiresConfirmation(kind)) + return null; + + var name = Describe(sessionKey, displayName); + + return kind switch + { + SessionActionKind.Reset => new SessionActionPrompt( + kind, + name, + "Reset session?", + $"Start a fresh session for \u201C{name}\u201D? The current conversation context will be cleared.", + "Reset", + IsDestructive: true), + + SessionActionKind.Compact => new SessionActionPrompt( + kind, + name, + "Compact session log?", + $"Keep the most recent messages for \u201C{name}\u201D and archive the rest. " + + "This creates a compaction checkpoint; export the transcript first if you need the full history.", + "Compact", + IsDestructive: false), + + SessionActionKind.Delete => new SessionActionPrompt( + kind, + name, + "Delete session?", + $"Delete \u201C{name}\u201D and archive its transcript? It will be removed from the session list.", + "Delete", + IsDestructive: true), + + SessionActionKind.Restore => new SessionActionPrompt( + kind, + name, + "Restore checkpoint?", + $"Roll \u201C{name}\u201D back to this compaction checkpoint? Messages added after the checkpoint will be archived.", + "Restore", + IsDestructive: true), + + _ => null, + }; + } + + /// Friendly label for a session, preferring its display name. + public static string Describe(string sessionKey, string? displayName) => + !string.IsNullOrWhiteSpace(displayName) ? displayName! : sessionKey; +} diff --git a/src/OpenClaw.Shared/Sessions/SessionCheckpointSelection.cs b/src/OpenClaw.Shared/Sessions/SessionCheckpointSelection.cs new file mode 100644 index 000000000..60b24c5df --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionCheckpointSelection.cs @@ -0,0 +1,30 @@ +namespace OpenClaw.Shared.Sessions; + +/// +/// Selection policy for compaction checkpoints. Branching can use a displayed +/// checkpoint directly, but restore is destructive, so it should only target a +/// checkpoint when the newest entry is unambiguous. +/// +public static class SessionCheckpointSelection +{ + /// + /// Returns the checkpoint that is provably the most recent, or + /// null when that can't be established. Restore archives every + /// message after the checkpoint, so callers should refuse to restore when + /// this returns null rather than guessing. + /// + public static SessionCompactionCheckpoint? ResolveUnambiguousLatest( + IReadOnlyList checkpoints) + { + if (checkpoints.Count == 0) return null; + + if (checkpoints.Any(c => c.CreatedAt is null)) return null; + + var ordered = checkpoints.OrderByDescending(c => c.CreatedAt!.Value).ToList(); + var latest = ordered[0]; + if (string.IsNullOrEmpty(latest.Id)) return null; + if (ordered.Count == 1) return latest; + + return latest.CreatedAt!.Value > ordered[1].CreatedAt!.Value ? latest : null; + } +} diff --git a/src/OpenClaw.Shared/Sessions/SessionTranscriptFormatter.cs b/src/OpenClaw.Shared/Sessions/SessionTranscriptFormatter.cs new file mode 100644 index 000000000..b846cea9a --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionTranscriptFormatter.cs @@ -0,0 +1,70 @@ +using System; +using System.Text; + +namespace OpenClaw.Shared.Sessions; + +/// +/// Renders a transcript to plain text for the +/// "Export transcript" action, and suggests a filename. Pure and testable. +/// +public static class SessionTranscriptFormatter +{ + /// + /// Formats the transcript as a readable plain-text document with a header + /// and one block per message (role, local timestamp, text). + /// + public static string Format(ChatHistoryInfo history) + { + if (history is null) throw new ArgumentNullException(nameof(history)); + + var nl = Environment.NewLine; + var sb = new StringBuilder(); + sb.Append("OpenClaw session transcript").Append(nl); + sb.Append("Session: ").Append(string.IsNullOrWhiteSpace(history.SessionKey) ? "(unknown)" : history.SessionKey).Append(nl); + if (!string.IsNullOrWhiteSpace(history.SessionId)) + sb.Append("Session ID: ").Append(history.SessionId).Append(nl); + sb.Append("Exported: ").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).Append(nl); + sb.Append("Messages: ").Append(history.Messages.Count).Append(nl); + sb.Append(new string('-', 40)).Append(nl); + + foreach (var m in history.Messages) + { + var role = string.IsNullOrWhiteSpace(m.Role) ? "?" : m.Role; + sb.Append(nl).Append('[').Append(role); + if (m.Ts > 0) + { + var ts = DateTimeOffset.FromUnixTimeMilliseconds(m.Ts).LocalDateTime; + sb.Append(" \u00B7 ").Append(ts.ToString("yyyy-MM-dd HH:mm:ss")); + } + sb.Append(']').Append(nl); + sb.Append(m.Text ?? string.Empty).Append(nl); + } + + return sb.ToString(); + } + + /// + /// A filesystem-safe suggested filename for the export, derived from the + /// session key and current date. + /// + public static string SuggestFileName(string? sessionKey) + { + var slug = Slugify(sessionKey); + return $"openclaw-transcript-{slug}-{DateTime.Now:yyyyMMdd-HHmmss}.txt"; + } + + private static string Slugify(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return "session"; + var sb = new StringBuilder(value!.Length); + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch)) sb.Append(char.ToLowerInvariant(ch)); + else if (ch is '-' or '_') sb.Append(ch); + else sb.Append('-'); + } + var slug = sb.ToString().Trim('-'); + if (slug.Length == 0) return "session"; + return slug.Length > 48 ? slug[..48].Trim('-') : slug; + } +} diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 91204faa0..7976b6c67 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; +using OpenClaw.Shared.Sessions; using OpenClaw.Shared.Mxc; using OpenClawTray.Dialogs; using OpenClawTray.Helpers; @@ -1121,30 +1122,57 @@ private async Task ExecuteSessionActionAsync(string action, string sessionKey, s { if (action is "reset" or "compact" or "delete") { - var title = action switch + var kind = action switch { - "reset" => "Reset session?", - "compact" => "Compact session log?", - "delete" => "Delete session?", - _ => "Confirm session action" + "reset" => SessionActionKind.Reset, + "compact" => SessionActionKind.Compact, + _ => SessionActionKind.Delete, }; - var body = action switch + + var session = _appState?.Sessions?.FirstOrDefault(s => s.Key == sessionKey); + var mainState = SessionActionPlanner.ResolveMainState( + sessionKey, + rowIsMain: session?.IsMain, + mainSessionKey: client.MainSessionKey, + sessions: _appState?.Sessions); + var isMain = mainState == SessionMainState.Main; + var displayName = session?.DisplayName; + + if (!SessionActionPlanner.IsAllowed(kind, mainState, out var blockedReason)) { - "reset" => $"Start a fresh session for '{sessionKey}'?", - "compact" => $"Keep the latest log lines for '{sessionKey}' and archive the rest?", - "delete" => $"Delete '{sessionKey}' and archive its transcript?", - _ => "Continue?" - }; - var button = action switch + _toastService!.ShowToast(new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_SessionActionFailed")) + .AddText(blockedReason ?? string.Empty)); + return; + } + + var prompt = SessionActionPlanner.BuildPrompt(kind, sessionKey, displayName, isMain); + if (prompt is not null) { - "reset" => "Reset", - "compact" => "Compact", - "delete" => "Delete", - _ => "Continue" - }; + var localizedPrompt = SessionActionPromptLocalizer.Localize(prompt); + var confirmed = await ConfirmSessionActionAsync( + localizedPrompt.Title, + localizedPrompt.Body, + localizedPrompt.ConfirmLabel); + if (!confirmed) return; + } + } - var confirmed = await ConfirmSessionActionAsync(title, body, button); - if (!confirmed) return; + if (action == "delete") + { + var session = _appState?.Sessions?.FirstOrDefault(s => s.Key == sessionKey); + var mainState = SessionActionPlanner.ResolveMainState( + sessionKey, + rowIsMain: session?.IsMain, + mainSessionKey: client.MainSessionKey, + sessions: _appState?.Sessions); + if (!SessionActionPlanner.IsAllowed(SessionActionKind.Delete, mainState, out var blockedReason)) + { + _toastService!.ShowToast(new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_SessionActionFailed")) + .AddText(blockedReason ?? string.Empty)); + return; + } } var sent = action switch @@ -1197,7 +1225,7 @@ private async Task ConfirmSessionActionAsync(string title, string body, st Title = title, Content = body, PrimaryButtonText = actionLabel, - CloseButtonText = "Cancel", + CloseButtonText = LocalizationHelper.GetString("CancelButton.Content"), DefaultButton = ContentDialogButton.Close, XamlRoot = root.XamlRoot }; diff --git a/src/OpenClaw.Tray.WinUI/Helpers/SessionActionPromptLocalizer.cs b/src/OpenClaw.Tray.WinUI/Helpers/SessionActionPromptLocalizer.cs new file mode 100644 index 000000000..def45d21b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/SessionActionPromptLocalizer.cs @@ -0,0 +1,28 @@ +using OpenClaw.Shared.Sessions; + +namespace OpenClawTray.Helpers; + +internal static class SessionActionPromptLocalizer +{ + public static SessionActionPrompt Localize(SessionActionPrompt prompt) + { + var prefix = prompt.Kind switch + { + SessionActionKind.Reset => "SessionActionPrompt_Reset", + SessionActionKind.Compact => "SessionActionPrompt_Compact", + SessionActionKind.Delete => "SessionActionPrompt_Delete", + SessionActionKind.Restore => "SessionActionPrompt_Restore", + _ => null, + }; + + if (prefix is null) + return prompt; + + return prompt with + { + Title = LocalizationHelper.GetString($"{prefix}_Title"), + Body = LocalizationHelper.Format($"{prefix}_BodyFormat", prompt.SessionName), + ConfirmLabel = LocalizationHelper.GetString($"{prefix}_ConfirmLabel"), + }; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index 628e317c8..511384dee 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -203,11 +203,34 @@ IsTextScaleFactorEnabled="False"/> + + + + + + + + + + _unloaded = false; Unloaded += (_, _) => { + _unloaded = true; _refreshTimer?.Stop(); _refreshTimer = null; if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged; + if (_subscribedClient != null) + { + _subscribedClient.SessionCommandCompleted -= OnSessionCommandCompleted; + _subscribedClient = null; + } }; } @@ -40,6 +52,18 @@ public void Initialize() _appState.PropertyChanged += OnAppStateChanged; var client = CurrentApp.GatewayClient; + + // Rebind when the client instance changes so a cached page never holds + // a stale command-result subscription. + if (_subscribedClient != client) + { + if (_subscribedClient != null) + _subscribedClient.SessionCommandCompleted -= OnSessionCommandCompleted; + _subscribedClient = client; + if (_subscribedClient != null) + _subscribedClient.SessionCommandCompleted += OnSessionCommandCompleted; + } + if (client == null) { _sessionLoading.Fail(); @@ -183,6 +207,13 @@ private SessionViewModel ToViewModel(SessionInfo s) if (s.ContextTokens > 0 && s.TotalTokens > 0) contextPercent = Math.Min(100.0, (double)s.TotalTokens / s.ContextTokens * 100.0); + var mainState = SessionActionPlanner.ResolveMainState( + s.Key, + rowIsMain: s.IsMain, + mainSessionKey: CurrentApp.GatewayClient?.MainSessionKey, + sessions: _appState?.Sessions); + var isMain = mainState == SessionMainState.Main; + return new SessionViewModel { Key = s.Key, @@ -195,6 +226,8 @@ private SessionViewModel ToViewModel(SessionInfo s) ContextPercent = contextPercent, HasTokenData = hasTokens || contextPercent > 0, CanEdit = _sessionLoading.CanEdit, + IsMain = isMain, + CanDelete = _sessionLoading.CanEdit && SessionActionPlanner.IsAllowed(SessionActionKind.Delete, mainState, out _), }; } @@ -271,49 +304,478 @@ private void ChannelSelector_SelectionChanged(SelectorBar sender, SelectorBarSel return null; } + private static SessionViewModel? ResolveSessionVm(object sender) + { + if (sender is FrameworkElement fe) + { + if (fe.DataContext is SessionViewModel vm && !string.IsNullOrEmpty(vm.Key)) + return vm; + if (fe is MenuFlyoutItem mfi && mfi.Parent is MenuFlyout mf + && mf.Target is FrameworkElement target + && target.DataContext is SessionViewModel targetVm + && !string.IsNullOrEmpty(targetVm.Key)) + return targetVm; + } + return null; + } + private void OnResetSession(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( - () => OnResetSessionAsync(sender), + () => RunSessionActionAsync(sender, SessionActionKind.Reset), new OpenClawTray.AppLogger(), nameof(OnResetSession)); - private async Task OnResetSessionAsync(object sender) + private void OnDeleteSession(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + () => RunSessionActionAsync(sender, SessionActionKind.Delete), + new OpenClawTray.AppLogger(), + nameof(OnDeleteSession)); + + private void OnCompactSession(object sender, RoutedEventArgs e) => + AsyncEventHandlerGuard.Run( + () => RunSessionActionAsync(sender, SessionActionKind.Compact), + new OpenClawTray.AppLogger(), + nameof(OnCompactSession)); + + private async Task RunSessionActionAsync(object sender, SessionActionKind kind) { - if (ResolveSessionKey(sender) is not string key) return; + var vm = ResolveSessionVm(sender); + var key = vm?.Key ?? ResolveSessionKey(sender); + if (string.IsNullOrEmpty(key)) return; + var client = CurrentApp.GatewayClient; if (client == null) { ShowDisconnected(); return; } - try { await client.ResetSessionAsync(key); } - catch (Exception ex) { ShowActionFailure("Reset failed", ex); } + + var isMainState = ResolveMainState(key, vm); + var isMain = isMainState == SessionMainState.Main; + var displayName = vm?.DisplayName; + + if (!SessionActionPlanner.IsAllowed(kind, isMainState, out var blockedReason)) + { + ShowActionInfo("Action unavailable", blockedReason ?? "This action isn't available.", InfoBarSeverity.Informational); + return; + } + + var prompt = SessionActionPlanner.BuildPrompt(kind, key, displayName, isMain); + if (prompt is not null && !await ConfirmAsync(prompt)) + return; + + try + { + if (kind == SessionActionKind.Delete) + { + var latestState = ResolveMainState(key, vm); + if (!SessionActionPlanner.IsAllowed(kind, latestState, out blockedReason)) + { + ShowActionInfo("Action unavailable", blockedReason ?? "Delete isn't available for this session.", InfoBarSeverity.Informational); + return; + } + } + + var sent = kind switch + { + SessionActionKind.Reset => await client.ResetSessionAsync(key), + SessionActionKind.Compact => await client.CompactSessionAsync(key), + SessionActionKind.Delete => await client.DeleteSessionAsync(key), + _ => true, + }; + if (!sent) + ShowActionInfo($"{kind} failed", "The gateway didn't accept the request. Try again.", InfoBarSeverity.Error); + } + catch (Exception ex) + { + ShowActionFailure($"{kind} failed", ex); + } } - private void OnDeleteSession(object sender, RoutedEventArgs e) => + private SessionMainState ResolveMainState(string key, SessionViewModel? vm) + => SessionActionPlanner.ResolveMainState( + key, + rowIsMain: vm?.IsMain, + mainSessionKey: CurrentApp.GatewayClient?.MainSessionKey, + sessions: _appState?.Sessions); + + private void OnExportSession(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( - () => OnDeleteSessionAsync(sender), + () => OnExportSessionAsync(sender), new OpenClawTray.AppLogger(), - nameof(OnDeleteSession)); + nameof(OnExportSession)); - private async Task OnDeleteSessionAsync(object sender) + private async Task OnExportSessionAsync(object sender) { - if (ResolveSessionKey(sender) is not string key) return; + var vm = ResolveSessionVm(sender); + var key = vm?.Key ?? ResolveSessionKey(sender); + if (string.IsNullOrEmpty(key)) return; + var client = CurrentApp.GatewayClient; if (client == null) { ShowDisconnected(); return; } - try { await client.DeleteSessionAsync(key); } - catch (Exception ex) { ShowActionFailure("Delete failed", ex); } + + var hwnd = ResolveHostHwnd(); + if (hwnd == IntPtr.Zero) + { + ShowActionInfo("Export unavailable", "Open the app window before exporting a transcript.", InfoBarSeverity.Informational); + return; + } + + ChatHistoryInfo history; + try + { + history = await client.RequestChatHistoryAsync(key); + } + catch (NotSupportedException) + { + ShowActionInfo("Not supported", "This gateway doesn't support exporting a transcript. Update the gateway to use this.", InfoBarSeverity.Informational); + return; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("unknown method", StringComparison.OrdinalIgnoreCase)) + { + ShowActionInfo("Not supported", "This gateway doesn't support exporting a transcript. Update the gateway to use this.", InfoBarSeverity.Informational); + return; + } + catch (Exception ex) + { + ShowActionFailure("Export failed", ex); + return; + } + + if (history.Messages.Count == 0) + { + ShowActionInfo("Nothing to export", "This session has no transcript yet.", InfoBarSeverity.Informational); + return; + } + + try + { + var picker = new FileSavePicker + { + SuggestedStartLocation = PickerLocationId.Desktop, + SuggestedFileName = System.IO.Path.GetFileNameWithoutExtension( + SessionTranscriptFormatter.SuggestFileName(key)), + }; + picker.FileTypeChoices.Add("Text file", new List { ".txt" }); + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + + var file = await picker.PickSaveFileAsync(); + if (file == null) return; // user cancelled + + await FileIO.WriteTextAsync(file, SessionTranscriptFormatter.Format(history)); + ShowActionInfo("Transcript exported", $"Saved {history.Messages.Count} messages to {file.Name}.", InfoBarSeverity.Success); + } + catch (Exception ex) + { + ShowActionFailure("Export failed", ex); + } } - private void OnCompactSession(object sender, RoutedEventArgs e) => + private void OnShowCheckpoints(object sender, RoutedEventArgs e) => AsyncEventHandlerGuard.Run( - () => OnCompactSessionAsync(sender), + () => OnShowCheckpointsAsync(sender), new OpenClawTray.AppLogger(), - nameof(OnCompactSession)); + nameof(OnShowCheckpoints)); - private async Task OnCompactSessionAsync(object sender) + private async Task OnShowCheckpointsAsync(object sender) { - if (ResolveSessionKey(sender) is not string key) return; + var vm = ResolveSessionVm(sender); + var key = vm?.Key ?? ResolveSessionKey(sender); + if (string.IsNullOrEmpty(key)) return; + if (XamlRoot == null) return; + var client = CurrentApp.GatewayClient; if (client == null) { ShowDisconnected(); return; } - try { await client.CompactSessionAsync(key); } - catch (Exception ex) { ShowActionFailure("Compact failed", ex); } + + var name = SessionActionPlanner.Describe(key, vm?.DisplayName); + var isMainState = ResolveMainState(key, vm); + var isMain = isMainState == SessionMainState.Main; + + SessionCompactionCheckpointList list; + try + { + list = await client.ListCompactionCheckpointsAsync(key); + } + catch (Exception ex) + { + ShowActionFailure("Couldn't load checkpoints", ex); + return; + } + + if (!list.IsSupported) + { + ShowActionInfo("Not supported", "This gateway doesn't support session compaction checkpoints. Update the gateway to use this.", InfoBarSeverity.Informational); + return; + } + + if (_unloaded || XamlRoot == null) + return; + + var checkpoints = list.Checkpoints + .OrderByDescending(c => c.CreatedAt ?? DateTime.MinValue) + .ToList(); + + var branchTarget = checkpoints.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c.Id)); + var restoreTarget = SessionCheckpointSelection.ResolveUnambiguousLatest(checkpoints); + var canRestore = restoreTarget is not null + && SessionActionPlanner.IsAllowed(SessionActionKind.Restore, isMainState, out _); + var actionHint = BuildCheckpointActionHint(checkpoints.Count, branchTarget, restoreTarget, canRestore, isMainState); + + var body = new StackPanel { Spacing = 12 }; + + if (checkpoints.Count == 0) + { + body.Children.Add(new TextBlock + { + Text = "No compaction checkpoints yet. Compacting this session creates one you can branch from or restore to.", + TextWrapping = TextWrapping.Wrap, + }); + } + else + { + body.Children.Add(new TextBlock + { + Text = $"{checkpoints.Count} checkpoint{(checkpoints.Count == 1 ? "" : "s")} \u00B7 newest first", + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + }); + + var listPanel = new StackPanel { Spacing = 4 }; + foreach (var cp in checkpoints) + { + listPanel.Children.Add(new TextBlock + { + Text = "\u2022 " + DescribeCheckpoint(cp), + TextWrapping = TextWrapping.Wrap, + }); + } + body.Children.Add(listPanel); + + body.Children.Add(new TextBlock + { + Text = actionHint, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + TextWrapping = TextWrapping.Wrap, + }); + } + + var dialog = new ContentDialog + { + Title = $"Checkpoints \u2014 {name}", + Content = body, + PrimaryButtonText = branchTarget is not null + ? (restoreTarget is not null ? "Branch from latest" : "Branch from latest targetable") + : "", + SecondaryButtonText = canRestore ? "Restore latest" : "", + CloseButtonText = "Close", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary && branchTarget is not null) + await BranchCheckpointAsync(key, branchTarget.Id); + else if (result == ContentDialogResult.Secondary && restoreTarget is not null) + await RestoreCheckpointAsync(key, name, isMain, restoreTarget.Id); + } + + private static string DescribeCheckpoint(SessionCompactionCheckpoint cp) + { + var parts = new List(3); + if (cp.CreatedAt is { } ts) parts.Add(ts.ToLocalTime().ToString("g")); + if (!string.IsNullOrWhiteSpace(cp.Reason)) parts.Add(cp.Reason!); + if (cp.TokensBefore is { } tb && cp.TokensAfter is { } ta) parts.Add($"{tb:n0}\u2192{ta:n0} tokens"); + + var head = parts.Count > 0 + ? string.Join(" \u00B7 ", parts) + : (string.IsNullOrEmpty(cp.Id) ? "checkpoint" : cp.Id); + + if (!string.IsNullOrWhiteSpace(cp.Summary)) + head += $" \u2014 {cp.Summary}"; + return head; + } + + private static string BuildCheckpointActionHint( + int checkpointCount, + SessionCompactionCheckpoint? branchTarget, + SessionCompactionCheckpoint? restoreTarget, + bool canRestore, + SessionMainState mainState) + { + if (checkpointCount <= 0) + return ""; + + if (canRestore) + { + return "Actions apply to the most recent checkpoint (top of the list). " + + "Branch starts a new session from it; Restore rolls this session back to it."; + } + + var reason = mainState == SessionMainState.Main + ? "Restore is unavailable for the main session." + : restoreTarget is null + ? "Restore is unavailable because the latest checkpoint can't be determined safely." + : "Restore is unavailable for this session."; + + var branchText = branchTarget is null + ? "Branch is unavailable because no checkpoint has a checkpoint id." + : "Branch starts a new session from the latest targetable checkpoint."; + return branchText + " " + reason; + } + + private async Task BranchCheckpointAsync(string key, string checkpointId) + { + if (string.IsNullOrWhiteSpace(checkpointId)) + { + ShowActionInfo("Action unavailable", "This checkpoint can't be branched because it has no checkpoint id.", InfoBarSeverity.Informational); + return; + } + + var client = CurrentApp.GatewayClient; + if (client == null) { ShowDisconnected(); return; } + + SessionCompactionMutationResult result; + try + { + result = await client.BranchCompactionCheckpointAsync(key, checkpointId); + } + catch (Exception ex) + { + ShowActionFailure("Branch failed", ex); + return; + } + + if (!result.IsSupported) + ShowActionInfo("Not supported", "This gateway doesn't support branching from a checkpoint. Update the gateway to use this.", InfoBarSeverity.Informational); + else if (result.Ok) + { + ShowActionInfo("Branched", result.ResultSessionKey is { Length: > 0 } nk ? $"Created session {nk}." : "Created a new session from the checkpoint.", InfoBarSeverity.Success); + _ = client.RequestSessionsAsync(); + } + else + ShowActionInfo("Branch failed", result.Error ?? "Could not branch from the checkpoint.", InfoBarSeverity.Error); + } + + private async Task RestoreCheckpointAsync(string key, string name, bool isMain, string checkpointId) + { + var mainState = ResolveMainState(key, null); + if (isMain && mainState == SessionMainState.NotMain) + mainState = SessionMainState.Main; + + if (!SessionActionPlanner.IsAllowed(SessionActionKind.Restore, mainState, out var blockedReason)) + { + ShowActionInfo("Action unavailable", blockedReason ?? "Restore isn't available for this session.", InfoBarSeverity.Informational); + return; + } + + var prompt = SessionActionPlanner.BuildPrompt(SessionActionKind.Restore, key, name, mainState == SessionMainState.Main); + if (prompt is not null && !await ConfirmAsync(prompt)) + return; + + var client = CurrentApp.GatewayClient; + if (client == null) { ShowDisconnected(); return; } + + mainState = ResolveMainState(key, null); + if (isMain && mainState == SessionMainState.NotMain) + mainState = SessionMainState.Main; + if (!SessionActionPlanner.IsAllowed(SessionActionKind.Restore, mainState, out blockedReason)) + { + ShowActionInfo("Action unavailable", blockedReason ?? "Restore isn't available for this session.", InfoBarSeverity.Informational); + return; + } + + // Re-check before restore so a concurrent compaction cannot make the + // confirmed "latest" checkpoint stale. + try + { + var fresh = await client.ListCompactionCheckpointsAsync(key); + if (!fresh.IsSupported) + { + ShowActionInfo("Not supported", "This gateway doesn't support restoring a checkpoint. Update the gateway to use this.", InfoBarSeverity.Informational); + return; + } + + var freshLatest = SessionCheckpointSelection.ResolveUnambiguousLatest(fresh.Checkpoints); + if (freshLatest is null || !string.Equals(freshLatest.Id, checkpointId, StringComparison.Ordinal)) + { + ShowActionInfo("Checkpoints changed", "The latest checkpoint changed since you opened this. Reopen Checkpoints and try again.", InfoBarSeverity.Warning); + return; + } + } + catch (Exception ex) + { + ShowActionFailure("Restore failed", ex); + return; + } + + SessionCompactionMutationResult result; + try + { + result = await client.RestoreCompactionCheckpointAsync(key, checkpointId); + } + catch (Exception ex) + { + ShowActionFailure("Restore failed", ex); + return; + } + + if (!result.IsSupported) + ShowActionInfo("Not supported", "This gateway doesn't support restoring a checkpoint. Update the gateway to use this.", InfoBarSeverity.Informational); + else if (result.Ok) + { + ShowActionInfo("Restored", "Rolled the session back to the checkpoint.", InfoBarSeverity.Success); + _ = client.RequestSessionsAsync(); + } + else + ShowActionInfo("Restore failed", result.Error ?? "Could not restore the checkpoint.", InfoBarSeverity.Error); + } + + private async Task ConfirmAsync(SessionActionPrompt prompt) + { + if (XamlRoot == null) return false; + var localizedPrompt = SessionActionPromptLocalizer.Localize(prompt); + var dialog = new ContentDialog + { + Title = localizedPrompt.Title, + Content = localizedPrompt.Body, + PrimaryButtonText = localizedPrompt.ConfirmLabel, + CloseButtonText = LocalizationHelper.GetString("CancelButton.Content"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + if (localizedPrompt.IsDestructive) + dialog.PrimaryButtonStyle = (Style)Application.Current.Resources["AccentButtonStyle"]; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + + private IntPtr ResolveHostHwnd() + { + var window = CurrentApp.ActiveHubWindow; + if (window == null) return IntPtr.Zero; + try { return WinRT.Interop.WindowNative.GetWindowHandle(window); } + catch { return IntPtr.Zero; } + } + + private void OnSessionCommandCompleted(object? sender, SessionCommandResult result) + { + DispatcherQueue.TryEnqueue(() => + { + if (_unloaded) return; + + if (string.Equals(result.Method, "sessions.compact", StringComparison.Ordinal) && result.Ok) + { + if (result.Compacted == true) + { + var kept = result.Kept.HasValue ? $" Kept {result.Kept.Value} lines." : ""; + ShowActionInfo("Checkpoint created", $"Compacted {result.Key ?? "session"}.{kept} View it from the session's Checkpoints menu.", InfoBarSeverity.Success); + } + else if (result.Compacted == false) + { + ShowActionInfo("Nothing to compact", $"{result.Key ?? "Session"} was already compact; no checkpoint was created.", InfoBarSeverity.Informational); + } + else + { + ShowActionInfo("Session compacted", $"Compacted {result.Key ?? "session"}. Refresh Checkpoints to see any new entries.", InfoBarSeverity.Success); + } + } + ApplyFilter(); + }); } private void OnRefresh(object sender, RoutedEventArgs e) @@ -367,6 +829,14 @@ private void ShowActionFailure(string title, Exception ex) ConnectionInfoBar.Severity = InfoBarSeverity.Error; ConnectionInfoBar.IsOpen = true; } + + private void ShowActionInfo(string title, string message, InfoBarSeverity severity) + { + ConnectionInfoBar.Title = title; + ConnectionInfoBar.Message = message; + ConnectionInfoBar.Severity = severity; + ConnectionInfoBar.IsOpen = true; + } } public class SessionViewModel @@ -381,5 +851,7 @@ public class SessionViewModel public double ContextPercent { get; set; } public bool HasTokenData { get; set; } public bool CanEdit { get; set; } = true; + public bool IsMain { get; set; } + public bool CanDelete { get; set; } = true; public Visibility TokenRowVisibility => HasTokenData ? Visibility.Visible : Visibility.Collapsed; } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 178cd82e2..19d09231d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4855,9 +4855,57 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Compact + + Checkpoints + + + View, branch or restore compaction checkpoints + + + Export transcript + + + Save the conversation transcript to a text file + Delete + + Reset session? + + + Start a fresh session for “{0}”? The current conversation context will be cleared. + + + Reset + + + Compact session log? + + + Keep the most recent messages for “{0}” and archive the rest. This creates a compaction checkpoint; export the transcript first if you need the full history. + + + Compact + + + Delete session? + + + Delete “{0}” and archive its transcript? It will be removed from the session list. + + + Delete + + + Restore checkpoint? + + + Roll “{0}” back to this compaction checkpoint? Messages added after the checkpoint will be archived. + + + Restore + Settings diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 83c8c3fe4..85d8c45af 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4807,9 +4807,57 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Compacter + + Points de contrôle + + + Afficher, créer une branche ou restaurer des points de contrôle de compactage + + + Exporter la transcription + + + Enregistrer la transcription de la conversation dans un fichier texte + Supprimer + + Réinitialiser la session ? + + + Démarrer une nouvelle session pour « {0} » ? Le contexte de conversation actuel sera effacé. + + + Réinitialiser + + + Compacter le journal de session ? + + + Conserver les messages les plus récents pour « {0} » et archiver le reste. Cela crée un point de contrôle de compactage ; exportez d’abord la transcription si vous avez besoin de l’historique complet. + + + Compacter + + + Supprimer la session ? + + + Supprimer « {0} » et archiver sa transcription ? Elle sera retirée de la liste des sessions. + + + Supprimer + + + Restaurer le point de contrôle ? + + + Restaurer « {0} » à ce point de contrôle de compactage ? Les messages ajoutés après le point de contrôle seront archivés. + + + Restaurer + Paramètres diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 2909e069b..a6a7c2a70 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4808,9 +4808,57 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Compact maken + + Controlepunten + + + Compactiecontrolepunten bekijken, vertakken of herstellen + + + Transcript exporteren + + + Het gesprekstranscript opslaan als tekstbestand + Verwijderen + + Sessie resetten? + + + Een nieuwe sessie starten voor '{0}'? De huidige gesprekscontext wordt gewist. + + + Resetten + + + Sessielog compact maken? + + + Bewaar de meest recente berichten voor '{0}' en archiveer de rest. Hiermee wordt een compactiecontrolepunt gemaakt; exporteer eerst het transcript als u de volledige geschiedenis nodig hebt. + + + Compact maken + + + Sessie verwijderen? + + + '{0}' verwijderen en het transcript archiveren? De sessie wordt uit de sessielijst verwijderd. + + + Verwijderen + + + Controlepunt herstellen? + + + '{0}' terugzetten naar dit compactiecontrolepunt? Berichten die na het controlepunt zijn toegevoegd, worden gearchiveerd. + + + Herstellen + Instellingen diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index fd996ab63..3aec3d060 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4807,9 +4807,57 @@ 压缩 + + 检查点 + + + 查看、分支或还原压缩检查点 + + + 导出转录 + + + 将对话转录保存到文本文件 + 删除 + + 重置会话? + + + 为“{0}”启动新会话?当前对话上下文将被清除。 + + + 重置 + + + 压缩会话日志? + + + 保留“{0}”的最新消息并归档其余内容。这会创建一个压缩检查点;如果需要完整历史记录,请先导出转录。 + + + 压缩 + + + 删除会话? + + + 删除“{0}”并归档其转录?它将从会话列表中移除。 + + + 删除 + + + 还原检查点? + + + 将“{0}”回滚到此压缩检查点?检查点之后添加的消息将被归档。 + + + 还原 + 设置 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 82f5fbb9b..3968050c9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4807,9 +4807,57 @@ 壓縮 + + 檢查點 + + + 檢視、分支或還原壓縮檢查點 + + + 匯出逐字稿 + + + 將對話逐字稿儲存為文字檔 + 刪除 + + 重設工作階段? + + + 為「{0}」啟動新的工作階段?目前的對話內容將被清除。 + + + 重設 + + + 壓縮工作階段記錄? + + + 保留「{0}」的最新訊息並封存其餘內容。這會建立一個壓縮檢查點;如果需要完整歷程記錄,請先匯出逐字稿。 + + + 壓縮 + + + 刪除工作階段? + + + 刪除「{0}」並封存其逐字稿?它將從工作階段清單中移除。 + + + 刪除 + + + 還原檢查點? + + + 將「{0}」回復到此壓縮檢查點?檢查點之後新增的訊息將被封存。 + + + 還原 + 設定 diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs index be5ebbb39..68d7d4992 100644 --- a/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs +++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs @@ -84,7 +84,15 @@ public async Task NewProtocolMethods_RealWebSocketRoundTrip_SendCorrectWireFrame Assert.Equal("hello world", file.Content); Assert.Contains("\"sessionKey\"", server.FrameFor("sessions.files.get")); - // ── 3. sessions.compaction.list + branch (param key/checkpointId; branch returns sourceKey + new key) ── + // ── 3. chat.history (real client transcript export path) ── + var history = await client.RequestChatHistoryAsync(key, timeoutMs: rpc); + Assert.Equal("sid-1", history.SessionId); + var message = Assert.Single(history.Messages); + Assert.Equal("assistant", message.Role); + Assert.Equal("done", message.Text); + Assert.Contains("\"sessionKey\"", server.FrameFor("chat.history")); + + // ── 4. sessions.compaction.list + branch (param key/checkpointId; branch returns sourceKey + new key) ── var checkpoints = await client.ListCompactionCheckpointsAsync(key, timeoutMs: rpc); Assert.True(checkpoints.IsSupported); Assert.Equal("cp1", Assert.Single(checkpoints.Checkpoints).Id); @@ -95,7 +103,7 @@ public async Task NewProtocolMethods_RealWebSocketRoundTrip_SendCorrectWireFrame Assert.Equal("agent:main:branch-1", branch.ResultSessionKey); Assert.Contains("\"checkpointId\"", server.FrameFor("sessions.compaction.branch")); - // ── 4. sessions.patch SET then CLEAR (the tri-state proof) ── + // ── 5. sessions.patch SET then CLEAR (the tri-state proof) ── // PatchSessionAsync is fire-and-tracked (returns on send, not on // response), so wait for the captured frame to arrive on the server. var setOk = await client.PatchSessionAsync(key, new SessionPatch { Model = "gpt-5", FastMode = SessionFastMode.Auto }); @@ -218,6 +226,20 @@ private static void ConfigureResponders(LoopbackGatewayServer server) } }); + server.OnMethod("chat.history", _ => new + { + sessionId = "sid-1", + messages = new object[] + { + new + { + role = "assistant", + content = "done", + timestamp = 1700000000001L + } + } + }); + server.OnMethod("sessions.compaction.list", _ => new { ok = true, diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs index 4e42030ae..99723360c 100644 --- a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Text.Json; using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; using Xunit; namespace OpenClaw.Shared.Tests; @@ -538,6 +539,29 @@ public void ParseCompactionCheckpointList_ParsesCheckpoints() Assert.NotNull(cp.CreatedAt); } + [Fact] + public void ParseCompactionCheckpointList_PreservesUntargetableCheckpointsForRestoreSafety() + { + var payload = Parse(""" + { + "ok": true, + "key": "agent:main:main", + "checkpoints": [ + { "createdAt": 1700000001000, "reason": "missing-id" }, + { "checkpointId": "cp1", "createdAt": 1700000000000, "reason": "manual" } + ] + } + """); + + var list = OpenClawGatewayClient.ParseCompactionCheckpointList(payload, "agent:main:main"); + + Assert.Equal(2, list.Checkpoints.Count); + Assert.Equal("", list.Checkpoints[0].Id); + Assert.Equal("missing-id", list.Checkpoints[0].Reason); + Assert.Equal("cp1", list.Checkpoints[1].Id); + Assert.Null(SessionCheckpointSelection.ResolveUnambiguousLatest(list.Checkpoints)); + } + [Fact] public void ParseCompactionCheckpointResult_ParsesSingleCheckpoint() { diff --git a/tests/OpenClaw.Shared.Tests/SessionActionPlannerTests.cs b/tests/OpenClaw.Shared.Tests/SessionActionPlannerTests.cs new file mode 100644 index 000000000..a208813b2 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SessionActionPlannerTests.cs @@ -0,0 +1,176 @@ +using OpenClaw.Shared.Sessions; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class SessionActionPlannerTests +{ + [Theory] + [InlineData(SessionActionKind.Reset, true)] + [InlineData(SessionActionKind.Compact, true)] + [InlineData(SessionActionKind.Delete, true)] + [InlineData(SessionActionKind.Restore, true)] + [InlineData(SessionActionKind.Export, false)] + [InlineData(SessionActionKind.OpenChat, false)] + [InlineData(SessionActionKind.Branch, false)] + public void RequiresConfirmation_MatchesPolicy(SessionActionKind kind, bool expected) + { + Assert.Equal(expected, SessionActionPlanner.RequiresConfirmation(kind)); + } + + [Theory] + [InlineData(SessionActionKind.Reset, true)] + [InlineData(SessionActionKind.Delete, true)] + [InlineData(SessionActionKind.Restore, true)] + [InlineData(SessionActionKind.Compact, false)] + [InlineData(SessionActionKind.Export, false)] + [InlineData(SessionActionKind.OpenChat, false)] + public void IsDestructive_MatchesPolicy(SessionActionKind kind, bool expected) + { + Assert.Equal(expected, SessionActionPlanner.IsDestructive(kind)); + } + + [Fact] + public void IsAllowed_BlocksDeleteOfMainSession_WithReason() + { + var allowed = SessionActionPlanner.IsAllowed(SessionActionKind.Delete, isMainSession: true, out var reason); + + Assert.False(allowed); + Assert.False(string.IsNullOrWhiteSpace(reason)); + Assert.Contains("main session", reason!, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void IsAllowed_PermitsDeleteOfNonMainSession() + { + var allowed = SessionActionPlanner.IsAllowed(SessionActionKind.Delete, isMainSession: false, out var reason); + + Assert.True(allowed); + Assert.Null(reason); + } + + [Fact] + public void IsAllowed_BlocksRestoreOfMainSession_WithReason() + { + var allowed = SessionActionPlanner.IsAllowed(SessionActionKind.Restore, isMainSession: true, out var reason); + + Assert.False(allowed); + Assert.False(string.IsNullOrWhiteSpace(reason)); + } + + [Fact] + public void IsAllowed_PermitsRestoreOfNonMain_AndBranchOfMain() + { + Assert.True(SessionActionPlanner.IsAllowed(SessionActionKind.Restore, isMainSession: false, out var r1)); + Assert.Null(r1); + Assert.True(SessionActionPlanner.IsAllowed(SessionActionKind.Branch, isMainSession: true, out var r2)); + Assert.Null(r2); + } + + [Theory] + [InlineData(SessionActionKind.Delete)] + [InlineData(SessionActionKind.Restore)] + public void IsAllowed_BlocksDestructiveActions_WhenMainStateUnknown(SessionActionKind kind) + { + var allowed = SessionActionPlanner.IsAllowed(kind, SessionMainState.Unknown, out var reason); + + Assert.False(allowed); + Assert.False(string.IsNullOrWhiteSpace(reason)); + } + + [Theory] + [InlineData("main")] + [InlineData("agent:main")] + [InlineData("agent:main:main")] + public void ResolveMainState_ProtectsCanonicalMainKeyShapes(string key) + { + Assert.Equal(SessionMainState.Main, SessionActionPlanner.ResolveMainState(key)); + } + + [Fact] + public void ResolveMainState_UsesMainSessionKey_WhenAvailable() + { + Assert.Equal( + SessionMainState.Main, + SessionActionPlanner.ResolveMainState("agent:abc", mainSessionKey: "agent:abc")); + Assert.Equal( + SessionMainState.NotMain, + SessionActionPlanner.ResolveMainState("agent:abc", mainSessionKey: "agent:def")); + } + + [Fact] + public void ResolveMainState_UsesSessionList_WhenMainKeyUnavailable() + { + var sessions = new[] + { + new SessionInfo { Key = "mainish", IsMain = true }, + new SessionInfo { Key = "other", IsMain = false }, + }; + + Assert.Equal(SessionMainState.Main, SessionActionPlanner.ResolveMainState("mainish", sessions: sessions)); + Assert.Equal(SessionMainState.NotMain, SessionActionPlanner.ResolveMainState("other", sessions: sessions)); + Assert.Equal(SessionMainState.Unknown, SessionActionPlanner.ResolveMainState("missing", sessions: sessions)); + } + + [Theory] + [InlineData(SessionActionKind.Reset)] + [InlineData(SessionActionKind.Compact)] + public void IsAllowed_PermitsResetAndCompactOfMain(SessionActionKind kind) + { + Assert.True(SessionActionPlanner.IsAllowed(kind, isMainSession: true, out var reason)); + Assert.Null(reason); + } + + [Fact] + public void BuildPrompt_ReturnsNull_ForNonConfirmingActions() + { + Assert.Null(SessionActionPlanner.BuildPrompt(SessionActionKind.Export, "k", null, false)); + Assert.Null(SessionActionPlanner.BuildPrompt(SessionActionKind.OpenChat, "k", null, false)); + Assert.Null(SessionActionPlanner.BuildPrompt(SessionActionKind.Branch, "k", null, false)); + } + + [Fact] + public void BuildPrompt_Delete_IsDestructive_AndUsesDisplayName() + { + var prompt = SessionActionPlanner.BuildPrompt( + SessionActionKind.Delete, "agent:main:wa", "WhatsApp · Bob", isMainSession: false); + + Assert.NotNull(prompt); + Assert.Equal(SessionActionKind.Delete, prompt!.Kind); + Assert.Equal("WhatsApp \u00B7 Bob", prompt.SessionName); + Assert.True(prompt!.IsDestructive); + Assert.Equal("Delete", prompt.ConfirmLabel); + Assert.Contains("WhatsApp \u00B7 Bob", prompt.Body); + Assert.DoesNotContain("agent:main:wa", prompt.Body); + } + + [Fact] + public void BuildPrompt_Compact_IsNotDestructive_AndMentionsCheckpoint() + { + var prompt = SessionActionPlanner.BuildPrompt( + SessionActionKind.Compact, "main", null, isMainSession: true); + + Assert.NotNull(prompt); + Assert.False(prompt!.IsDestructive); + Assert.Equal("Compact", prompt.ConfirmLabel); + Assert.Contains("checkpoint", prompt.Body, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildPrompt_FallsBackToKey_WhenNoDisplayName() + { + var prompt = SessionActionPlanner.BuildPrompt( + SessionActionKind.Reset, "agent:main:main", null, isMainSession: true); + + Assert.NotNull(prompt); + Assert.Contains("agent:main:main", prompt!.Body); + } + + [Fact] + public void Describe_PrefersDisplayName_FallsBackToKey() + { + Assert.Equal("Pretty", SessionActionPlanner.Describe("key", "Pretty")); + Assert.Equal("key", SessionActionPlanner.Describe("key", null)); + Assert.Equal("key", SessionActionPlanner.Describe("key", " ")); + } +} diff --git a/tests/OpenClaw.Shared.Tests/SessionCheckpointSelectionTests.cs b/tests/OpenClaw.Shared.Tests/SessionCheckpointSelectionTests.cs new file mode 100644 index 000000000..fcd346225 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SessionCheckpointSelectionTests.cs @@ -0,0 +1,77 @@ +using OpenClaw.Shared.Sessions; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public sealed class SessionCheckpointSelectionTests +{ + private static SessionCompactionCheckpoint Checkpoint(string id, DateTime? createdAt) => new() + { + Id = id, + CreatedAt = createdAt, + }; + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNull_ForEmptyList() + { + Assert.Null(SessionCheckpointSelection.ResolveUnambiguousLatest(Array.Empty())); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsOnlyCheckpoint_WhenDated() + { + var checkpoint = Checkpoint("one", new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + + Assert.Same(checkpoint, SessionCheckpointSelection.ResolveUnambiguousLatest(new[] { checkpoint })); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNewest_WhenStrictlyNewer() + { + var older = Checkpoint("older", new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + var newer = Checkpoint("newer", new DateTime(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc)); + + var latest = SessionCheckpointSelection.ResolveUnambiguousLatest(new[] { older, newer }); + + Assert.Same(newer, latest); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNull_WhenAnyCheckpointLacksTimestamp() + { + var dated = Checkpoint("dated", new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + var unknown = Checkpoint("unknown", null); + + Assert.Null(SessionCheckpointSelection.ResolveUnambiguousLatest(new[] { dated, unknown })); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNull_WhenNewestTimestampTies() + { + var t = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + Assert.Null(SessionCheckpointSelection.ResolveUnambiguousLatest(new[] + { + Checkpoint("a", t), + Checkpoint("b", t), + })); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNull_WhenNewestCheckpointLacksId() + { + var older = Checkpoint("older", new DateTime(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc)); + var missingId = Checkpoint("", new DateTime(2026, 1, 1, 12, 10, 0, DateTimeKind.Utc)); + + Assert.Null(SessionCheckpointSelection.ResolveUnambiguousLatest(new[] { older, missingId })); + } + + [Fact] + public void ResolveUnambiguousLatest_ReturnsNewest_WhenOlderCheckpointLacksId() + { + var latest = Checkpoint("newer", new DateTime(2026, 1, 1, 12, 10, 0, DateTimeKind.Utc)); + var missingId = Checkpoint("", new DateTime(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc)); + + Assert.Same(latest, SessionCheckpointSelection.ResolveUnambiguousLatest(new[] { latest, missingId })); + } +} diff --git a/tests/OpenClaw.Shared.Tests/SessionTranscriptFormatterTests.cs b/tests/OpenClaw.Shared.Tests/SessionTranscriptFormatterTests.cs new file mode 100644 index 000000000..38312ff13 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SessionTranscriptFormatterTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using OpenClaw.Shared; +using OpenClaw.Shared.Sessions; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class SessionTranscriptFormatterTests +{ + [Fact] + public void Format_IncludesHeaderAndMessages() + { + var history = new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "uuid-1", + Messages = new List + { + new() { Role = "user", Text = "hello", Ts = 1735732800000 }, + new() { Role = "assistant", Text = "hi there" }, + }, + }; + + var text = SessionTranscriptFormatter.Format(history); + + Assert.Contains("OpenClaw session transcript", text); + Assert.Contains("Session: main", text); + Assert.Contains("Session ID: uuid-1", text); + Assert.Contains("Messages: 2", text); + Assert.Contains("[user", text); + Assert.Contains("hello", text); + Assert.Contains("[assistant]", text); + Assert.Contains("hi there", text); + } + + [Fact] + public void Format_HandlesEmptyTranscript() + { + var history = new ChatHistoryInfo { SessionKey = "main" }; + var text = SessionTranscriptFormatter.Format(history); + Assert.Contains("Messages: 0", text); + } + + [Fact] + public void Format_TimestampOnlyWhenPresent() + { + var history = new ChatHistoryInfo + { + SessionKey = "main", + Messages = new List { new() { Role = "assistant", Text = "x", Ts = 0 } }, + }; + + var text = SessionTranscriptFormatter.Format(history); + Assert.Contains("[assistant]", text); // no " · timestamp" suffix when Ts == 0 + } + + [Fact] + public void SuggestFileName_IsFilesystemSafe() + { + var name = SessionTranscriptFormatter.SuggestFileName("agent:main:wa/bob"); + + Assert.StartsWith("openclaw-transcript-", name); + Assert.EndsWith(".txt", name); + Assert.DoesNotContain(":", name); + Assert.DoesNotContain("/", name); + } + + [Fact] + public void SuggestFileName_FallsBackForBlankKey() + { + var name = SessionTranscriptFormatter.SuggestFileName(null); + Assert.Contains("session", name); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs index f8f8e821c..4d0fecb90 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs @@ -429,6 +429,9 @@ private sealed class FakeOperatorGatewayClient : IOperatorGatewayClient public Task SendChatMessageAsync(string message, string? sessionKey = null) => SendChatMessageForRunAsync(message, sessionKey); + public Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) => + Task.FromResult(new ChatHistoryInfo { SessionKey = sessionKey ?? "" }); + public Task SendChatMessageForRunAsync(string message, string? sessionKey = null) { SendCount++; diff --git a/tests/OpenClaw.Tray.Tests/PairingApprovalCoordinatorTests.cs b/tests/OpenClaw.Tray.Tests/PairingApprovalCoordinatorTests.cs index a0b17ddaf..ffdeecbee 100644 --- a/tests/OpenClaw.Tray.Tests/PairingApprovalCoordinatorTests.cs +++ b/tests/OpenClaw.Tray.Tests/PairingApprovalCoordinatorTests.cs @@ -114,6 +114,8 @@ private sealed class FakeOperatorGatewayClient : IOperatorGatewayClient public void SetUserRules(IReadOnlyList? rules) { } public void SetPreferStructuredCategories(bool value) { } public Task SendChatMessageAsync(string message, string? sessionKey = null) => Task.CompletedTask; + public Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) => + Task.FromResult(new ChatHistoryInfo { SessionKey = sessionKey ?? "" }); public Task SendChatMessageForRunAsync(string message, string? sessionKey = null) => Task.FromResult(new ChatSendResult()); public Task CheckHealthAsync() => Task.CompletedTask; public Task RequestSessionsAsync(string? agentId = null) => Task.CompletedTask; diff --git a/tests/OpenClaw.Tray.Tests/SessionActionsWiringTests.cs b/tests/OpenClaw.Tray.Tests/SessionActionsWiringTests.cs new file mode 100644 index 000000000..c6cf4b465 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SessionActionsWiringTests.cs @@ -0,0 +1,157 @@ +using System.IO; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Source-contract tests for the session-actions + compaction-checkpoint UX. +/// They assert the Sessions page and App route destructive actions through the +/// shared SessionActionPlanner (confirmation + main-session protection) +/// and expose export/checkpoint entry points, without needing a UI thread. +/// +public sealed class SessionActionsWiringTests +{ + [Fact] + public void SessionsPage_ConfirmsDestructiveActions_ViaPlanner() + { + var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "SessionsPage.xaml.cs"); + + // Destructive actions are gated by the shared planner + a confirm dialog. + Assert.Contains("SessionActionPlanner.IsAllowed", source); + Assert.Contains("SessionActionPlanner.BuildPrompt", source); + Assert.Contains("ConfirmAsync", source); + // Reset/Compact/Delete funnel through a single confirmed runner. + Assert.Contains("RunSessionActionAsync(sender, SessionActionKind.Reset)", source); + Assert.Contains("RunSessionActionAsync(sender, SessionActionKind.Compact)", source); + Assert.Contains("RunSessionActionAsync(sender, SessionActionKind.Delete)", source); + } + + [Fact] + public void SessionsPage_ExposesExportAndCheckpointActions() + { + var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "SessionsPage.xaml.cs"); + + // Export uses transcript fetch + formatter + file picker. + Assert.Contains("RequestChatHistoryAsync", source); + Assert.Contains("SessionTranscriptFormatter", source); + Assert.Contains("FileSavePicker", source); + + // Checkpoints use the gateway's compaction-checkpoint protocol APIs. + Assert.Contains("ListCompactionCheckpointsAsync", source); + Assert.Contains("BranchCompactionCheckpointAsync", source); + Assert.Contains("RestoreCompactionCheckpointAsync", source); + // Unsupported gateways are surfaced via the typed IsSupported flag. + Assert.Contains("IsSupported", source); + } + + [Fact] + public void SessionsPage_HardensDestructiveActions() + { + var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "SessionsPage.xaml.cs"); + + // Main-session gating uses an authoritative resolver, not a VM-only default. + Assert.Contains("ResolveMainState", source); + // Restore only acts on a provably-latest checkpoint and re-validates fresh. + Assert.Contains("ResolveUnambiguousLatest", source); + // ID-less checkpoints are preserved for restore safety, but can't be used as branch targets. + Assert.Contains("FirstOrDefault(c => !string.IsNullOrWhiteSpace(c.Id))", source); + // Destructive send failures are surfaced, not swallowed. + Assert.Contains("\"The gateway didn't accept the request. Try again.\"", source); + } + + [Fact] + public void SessionsPage_Xaml_HasActionMenuItems_AndGatesDelete() + { + var xaml = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "SessionsPage.xaml"); + + Assert.Contains("Click=\"OnExportSession\"", xaml); + Assert.Contains("Click=\"OnShowCheckpoints\"", xaml); + Assert.Contains("Click=\"OnDeleteSession\"", xaml); + // Delete is disabled for sessions that can't be deleted (main session). + Assert.Contains("IsEnabled=\"{Binding CanDelete}\"", xaml); + } + + [Fact] + public void App_SessionActions_UsePlanner_NotInlineCopy() + { + var source = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + + Assert.Contains("SessionActionPlanner.BuildPrompt", source); + Assert.Contains("SessionActionPlanner.IsAllowed", source); + // The confirmation copy comes from the shared planner, not inline strings. + Assert.DoesNotContain("Start a fresh session for '", source); + Assert.DoesNotContain("Keep the latest log lines for '", source); + } + + [Fact] + public void SessionActionPrompts_AreRuntimeLocalized() + { + var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Helpers", "SessionActionPromptLocalizer.cs"); + var requiredKeys = new[] + { + "SessionActionPrompt_Reset_Title", + "SessionActionPrompt_Reset_BodyFormat", + "SessionActionPrompt_Reset_ConfirmLabel", + "SessionActionPrompt_Compact_Title", + "SessionActionPrompt_Compact_BodyFormat", + "SessionActionPrompt_Compact_ConfirmLabel", + "SessionActionPrompt_Delete_Title", + "SessionActionPrompt_Delete_BodyFormat", + "SessionActionPrompt_Delete_ConfirmLabel", + "SessionActionPrompt_Restore_Title", + "SessionActionPrompt_Restore_BodyFormat", + "SessionActionPrompt_Restore_ConfirmLabel", + }; + + foreach (var prefix in new[] + { + "SessionActionPrompt_Reset", + "SessionActionPrompt_Compact", + "SessionActionPrompt_Delete", + "SessionActionPrompt_Restore", + }) + { + Assert.Contains(prefix, source); + } + + var stringsRoot = Path.Combine(GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Strings"); + foreach (var resourcePath in Directory.EnumerateFiles(stringsRoot, "Resources.resw", SearchOption.AllDirectories)) + { + var keys = XDocument.Load(resourcePath) + .Descendants("data") + .Select(e => e.Attribute("name")?.Value) + .Where(name => !string.IsNullOrEmpty(name)) + .ToHashSet(StringComparer.Ordinal); + foreach (var key in requiredKeys) + Assert.Contains(key, keys); + } + } + + private static string ReadSource(params string[] relativePathParts) + { + var root = GetRepositoryRoot(); + return File.ReadAllText(Path.Combine(new[] { root }.Concat(relativePathParts).ToArray())); + } + + private static string GetRepositoryRoot() + { + var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) + return env; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } +}