From 9b7dbd8dcd0234c94b0bde375e272d2abb9d74d5 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 30 Jul 2026 16:34:46 +0800 Subject: [PATCH 1/3] feat(sessions): consume flattened gateway classification --- src/OpenClaw.Shared/Models.cs | 32 +- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 60 +--- .../Sessions/SessionDisplayResolver.cs | 228 ++++++++++++++ .../Sessions/SessionPresentationResolver.cs | 296 ------------------ .../Chat/OpenClawChatDataProvider.cs | 8 +- .../Pages/SessionsPage.xaml.cs | 2 +- .../Services/SessionTitleFormatter.cs | 44 +-- .../Services/TrayDashboardSummary.cs | 2 +- .../Services/TrayMenuStateBuilder.cs | 2 +- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 52 +-- .../OpenClawGatewayClientTests.cs | 108 ++----- .../Protocol/gateway-protocol-snapshot.json | 2 +- .../SessionDisplayResolverTests.cs | 45 +++ .../SessionPresentationResolverTests.cs | 183 ----------- .../OpenClawChatDataProviderTests.cs | 20 +- .../TrayDashboardSummaryBuilderTests.cs | 8 +- .../SessionTitleFormatterTests.cs | 42 +-- 17 files changed, 375 insertions(+), 759 deletions(-) create mode 100644 src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs delete mode 100644 src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs create mode 100644 tests/OpenClaw.Shared.Tests/SessionDisplayResolverTests.cs delete mode 100644 tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index e295118fb..dbae70026 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -251,20 +251,6 @@ private static bool TryGetBool(JsonElement parent, string property) => } } -public sealed class SessionPresentationInfo -{ - public string Title { get; set; } = ""; - public string TitleSource { get; set; } = "generated"; - public string? Subtitle { get; set; } - public string Family { get; set; } = "custom"; - public string? AgentId { get; set; } - public string? Channel { get; set; } - public string? AccountId { get; set; } - public string? PeerKind { get; set; } - public bool IsMain { get; set; } - public bool IsBackground { get; set; } -} - public sealed class SessionWorktreeInfo { public string? Id { get; set; } @@ -279,14 +265,6 @@ public class SessionInfo public SessionInfo Clone() { var copy = (SessionInfo)MemberwiseClone(); - if (copy.Presentation is { } p) - copy.Presentation = new SessionPresentationInfo - { - Title = p.Title, TitleSource = p.TitleSource, Subtitle = p.Subtitle, - Family = p.Family, AgentId = p.AgentId, Channel = p.Channel, - AccountId = p.AccountId, PeerKind = p.PeerKind, - IsMain = p.IsMain, IsBackground = p.IsBackground, - }; if (copy.Worktree is { } w) copy.Worktree = new SessionWorktreeInfo { @@ -309,12 +287,16 @@ public SessionInfo Clone() public string? Room { get; set; } public string? Space { get; set; } public string? ChatType { get; set; } + public string? Classification { get; set; } + public string? AgentId { get; set; } + public string? AccountId { get; set; } + public string? PeerKind { get; set; } + public bool? IsBackground { get; set; } public string? OriginLabel { get; set; } public SessionWorktreeInfo? Worktree { get; set; } public string? ExecNode { get; set; } public string? ParentSessionKey { get; set; } public int? SpawnDepth { get; set; } - public SessionPresentationInfo? Presentation { get; set; } public string? SessionId { get; set; } public string? ThinkingLevel { get; set; } public string? VerboseLevel { get; set; } @@ -352,7 +334,7 @@ public string RichDisplayText { get { - var title = SessionPresentationResolver.Resolve(this).Title; + var title = SessionDisplayResolver.Resolve(this).Title; // Fixed-size array avoids List allocation; at most 9 detail slots. var details = new string?[9]; @@ -395,7 +377,7 @@ public string ContextSummaryShort /// Gets a shortened, user-friendly version of the session key. public string ShortKey { - get => SessionPresentationResolver.Resolve(this).Title; + get => SessionDisplayResolver.Resolve(this).Title; } } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 6208072c4..7baed4cc7 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -3792,16 +3792,6 @@ private void UpdateSessionMainStatus(SessionInfo session, string sessionKey, Jso return; } - if (item.ValueKind == JsonValueKind.Object - && item.TryGetProperty("presentation", out var presentation) - && presentation.ValueKind == JsonValueKind.Object - && TryGetRequiredBoolean(presentation, "isMain", out var presentationIsMain)) - { - session.IsMain = presentationIsMain; - session.IsMainResolved = true; - return; - } - if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("isMain", out var isMain) && isMain.ValueKind is JsonValueKind.True or JsonValueKind.False) @@ -3846,6 +3836,13 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item) session.Room = GetString(item, "room"); if (item.TryGetProperty("space", out _)) session.Space = GetString(item, "space"); if (item.TryGetProperty("chatType", out _)) session.ChatType = GetString(item, "chatType"); + if (item.TryGetProperty("classification", out _)) session.Classification = GetString(item, "classification"); + if (item.TryGetProperty("agentId", out _)) session.AgentId = GetString(item, "agentId"); + if (item.TryGetProperty("accountId", out _)) session.AccountId = GetString(item, "accountId"); + if (item.TryGetProperty("peerKind", out _)) session.PeerKind = GetString(item, "peerKind"); + if (item.TryGetProperty("isBackground", out var isBackground) + && isBackground.ValueKind is JsonValueKind.True or JsonValueKind.False) + session.IsBackground = isBackground.GetBoolean(); if (item.TryGetProperty("execNode", out _)) session.ExecNode = GetString(item, "execNode"); if (item.TryGetProperty("parentSessionKey", out _)) session.ParentSessionKey = GetString(item, "parentSessionKey"); @@ -3859,12 +3856,6 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item) ? GetString(origin, "label") : null; if (item.TryGetProperty("worktree", out _)) session.Worktree = ParseSessionWorktree(item); - if (item.TryGetProperty("presentation", out _)) - session.Presentation = ParseSessionPresentation(item); - if (session.Presentation is { } presentation) - { - session.Channel ??= presentation.Channel; - } if (item.TryGetProperty("sessionId", out var sessionId)) session.SessionId = sessionId.GetString(); if (item.TryGetProperty("thinkingLevel", out var thinking)) @@ -3916,43 +3907,6 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item) : new SessionWorktreeInfo { Id = id, Branch = branch, RepoRoot = repoRoot }; } - private static SessionPresentationInfo? ParseSessionPresentation(JsonElement item) - { - if (!item.TryGetProperty("presentation", out var presentation) - || presentation.ValueKind != JsonValueKind.Object) - return null; - - var title = GetString(presentation, "title"); - var family = GetString(presentation, "family"); - if (title is null - || family is null - || !TryGetRequiredBoolean(presentation, "isMain", out var isMain) - || !TryGetRequiredBoolean(presentation, "isBackground", out var isBackground)) - return null; - - return new SessionPresentationInfo - { - Title = title, - TitleSource = GetString(presentation, "titleSource") ?? "generated", - Subtitle = GetString(presentation, "subtitle"), - Family = family, - AgentId = GetString(presentation, "agentId"), - Channel = GetString(presentation, "channel"), - AccountId = GetString(presentation, "accountId"), - PeerKind = GetString(presentation, "peerKind"), - IsMain = isMain, - IsBackground = isBackground, - }; - } - - private static bool TryGetRequiredBoolean(JsonElement parent, string propertyName, out bool value) - { - value = false; - if (!parent.TryGetProperty(propertyName, out var property)) return false; - if (property.ValueKind == JsonValueKind.True) value = true; - return property.ValueKind is JsonValueKind.True or JsonValueKind.False; - } - private void ParseNodeList(JsonElement nodesPayload) { try diff --git a/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs b/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs new file mode 100644 index 000000000..9d1090088 --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs @@ -0,0 +1,228 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared; + +/// Builds Windows-local display text from flat Gateway session facts. +public static partial class SessionDisplayResolver +{ + private static readonly HashSet BackgroundClassifications = new(StringComparer.OrdinalIgnoreCase) + { + "acp", "cron", "dreaming", "harness", "heartbeat", "hook", "subagent", "system", + }; + + public static SessionDisplayInfo Resolve(SessionInfo session) + { + ArgumentNullException.ThrowIfNull(session); + + var fallback = ResolveKey(session.Key, session.IsMain, session.Channel, session.Worktree); + var classification = NonEmpty(session.Classification) ?? fallback.Classification; + var agentId = NonEmpty(session.AgentId) ?? fallback.AgentId; + var channel = NonEmpty(session.Channel) ?? fallback.Channel; + var accountId = NonEmpty(session.AccountId) ?? fallback.AccountId; + var peerKind = NonEmpty(session.PeerKind) ?? fallback.PeerKind; + var isDirect = classification.Equals("direct", StringComparison.OrdinalIgnoreCase) + || peerKind?.Equals("direct", StringComparison.OrdinalIgnoreCase) == true; + var title = UsefulTitle(session.Key, session.Label) + ?? (isDirect ? null : SafeLegacyDisplayName(session.DisplayName)) + ?? UsefulTitle(session.Key, session.DerivedTitle) + ?? TitleForClassification(classification, channel, session.Worktree, fallback.Title); + + return new SessionDisplayInfo + { + Title = title, + TitleSource = UsefulTitle(session.Key, session.Label) is not null ? "label" + : !isDirect && SafeLegacyDisplayName(session.DisplayName) is not null ? "displayName" + : UsefulTitle(session.Key, session.DerivedTitle) is not null ? "derivedTitle" + : "generated", + Subtitle = BuildSubtitle(channel, accountId, agentId, session.ExecNode, session.Worktree), + Classification = classification, + AgentId = agentId, + Channel = channel, + AccountId = accountId, + PeerKind = peerKind, + IsMain = session.IsMain, + IsBackground = session.IsBackground ?? BackgroundClassifications.Contains(classification), + }; + } + + public static bool IsBackground(SessionInfo session) => Resolve(session).IsBackground; + + public static bool IsVisible(SessionInfo session, bool showBackground) => + showBackground || !IsBackground(session); + + /// Produces a bounded context label while masking opaque identifiers. + public static string FormatContext(string value) + { + ArgumentNullException.ThrowIfNull(value); + return ReadableTail(value); + } + + private static SessionDisplayInfo ResolveKey( + string? rawKey, + bool isMain, + string? rowChannel, + SessionWorktreeInfo? worktree) + { + var key = rawKey?.Trim() ?? string.Empty; + if (key.Length == 0) + return isMain ? Display("Main session", "main", agentId: "main", isMain: true) : Display("Session", "unknown"); + if (key.Equals("global", StringComparison.OrdinalIgnoreCase)) + return Display("Global session", "global", agentId: isMain ? "main" : null, isMain: isMain); + if (key.Equals("unknown", StringComparison.OrdinalIgnoreCase)) + return Display("Unknown session", "unknown"); + + var (agentId, rest) = ParseAgentWrapper(key); + if (isMain) return Display("Main session", "main", agentId: agentId, isMain: true); + if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase) && rest.EndsWith(":heartbeat", StringComparison.OrdinalIgnoreCase)) + return Display("Heartbeat", "heartbeat", agentId, isBackground: true); + if (rest.Equals("subagent", StringComparison.OrdinalIgnoreCase) || rest.StartsWith("subagent:", StringComparison.OrdinalIgnoreCase)) + return Display("Subagent", "subagent", agentId, isBackground: true); + if (rest.Equals("acp", StringComparison.OrdinalIgnoreCase) || rest.StartsWith("acp:", StringComparison.OrdinalIgnoreCase)) + return Display("ACP session", "acp", agentId, isBackground: true); + if (rest.Equals("cron", StringComparison.OrdinalIgnoreCase) || rest.StartsWith("cron:", StringComparison.OrdinalIgnoreCase)) + return Display("Scheduled task", "cron", agentId, isBackground: true); + if (rest.StartsWith("dashboard:", StringComparison.OrdinalIgnoreCase)) + return Display(FormatWorktree(worktree) ?? "New session", "dashboard", agentId); + if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase)) return Display("Terminal session", "tui", agentId); + if (rest.StartsWith("explicit:", StringComparison.OrdinalIgnoreCase)) return Display(ReadableTail(rest["explicit:".Length..]), "explicit", agentId); + if (rest.StartsWith("hook:", StringComparison.OrdinalIgnoreCase)) return Display("Hook run", "hook", agentId, isBackground: true); + if (rest.StartsWith("harness:", StringComparison.OrdinalIgnoreCase)) return Display("Harness session", "harness", agentId, isBackground: true); + if (rest.StartsWith("voice:", StringComparison.OrdinalIgnoreCase)) return Display("Voice call", "voice", agentId); + if (rest.StartsWith("dreaming-narrative-", StringComparison.OrdinalIgnoreCase)) return Display("Dreaming", "dreaming", agentId, isBackground: true); + if (rest.Equals("boot", StringComparison.OrdinalIgnoreCase) || rest.StartsWith("commitments:", StringComparison.OrdinalIgnoreCase) || rest.StartsWith("internal-session-effects:", StringComparison.OrdinalIgnoreCase)) + return Display("Background task", "system", agentId, isBackground: true); + + var threadIndex = rest.LastIndexOf(":thread:", StringComparison.OrdinalIgnoreCase); + var route = ParseRoute(threadIndex >= 0 ? rest[..threadIndex] : rest, rowChannel); + if (threadIndex >= 0) + return Display(route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} thread" : "Thread", "thread", agentId, route.Channel, route.AccountId, route.PeerKind); + if (route.Classification is not null) + { + var noun = route.Classification switch { "direct" => "direct message", "group" => "group", _ => "channel" }; + return Display(route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} {noun}" : Capitalize(noun), route.Classification, agentId, route.Channel, route.AccountId, route.PeerKind); + } + return Display(ReadableTail(rest), "custom", agentId); + } + + private static string TitleForClassification( + string classification, + string? channel, + SessionWorktreeInfo? worktree, + string fallback) + { + var channelTitle = channel is { Length: > 0 } ? ChannelLabel(channel) : null; + return classification.ToLowerInvariant() switch + { + "main" => "Main session", + "global" => "Global session", + "unknown" => "Unknown session", + "direct" => channelTitle is null ? "Direct message" : $"{channelTitle} direct message", + "group" => channelTitle is null ? "Group conversation" : $"{channelTitle} group", + "channel" => channelTitle is null ? "Channel conversation" : $"{channelTitle} channel", + "thread" => channelTitle is null ? "Thread" : $"{channelTitle} thread", + "cron" => "Scheduled task", + "heartbeat" => "Heartbeat", + "subagent" => "Subagent", + "acp" => "ACP session", + "dashboard" => FormatWorktree(worktree) ?? "New session", + "tui" => "Terminal session", + "hook" => "Hook run", + "harness" => "Harness session", + "voice" => "Voice call", + "dreaming" => "Dreaming", + "system" => "Background task", + _ => fallback, + }; + } + + private static (string? AgentId, string Tail) ParseAgentWrapper(string key) + { + var first = key.IndexOf(':'); + var second = first >= 0 ? key.IndexOf(':', first + 1) : -1; + return first <= 0 || second <= first + 1 || !key[..first].Equals("agent", StringComparison.OrdinalIgnoreCase) + ? (null, key) : (key[(first + 1)..second], key[(second + 1)..]); + } + + private static (string? Classification, string? Channel, string? AccountId, string? PeerKind) ParseRoute(string rest, string? rowChannel) + { + var parts = rest.Split(':'); + if (parts.Length >= 2 && IsDirect(parts[0])) return ("direct", NonEmpty(rowChannel), null, "direct"); + if (parts.Length < 3) return (null, null, null, null); + var channel = NonEmpty(parts[0]); + if (IsPeerKind(parts[1])) return (NormalizeClassification(parts[1]), channel, null, NormalizePeerKind(parts[1])); + if (parts.Length >= 4 && IsPeerKind(parts[2])) return (NormalizeClassification(parts[2]), channel, NonEmpty(parts[1]), NormalizePeerKind(parts[2])); + return (null, null, null, null); + } + + private static bool IsDirect(string value) => value.Equals("direct", StringComparison.OrdinalIgnoreCase) || value.Equals("dm", StringComparison.OrdinalIgnoreCase); + private static bool IsPeerKind(string value) => IsDirect(value) || value.Equals("group", StringComparison.OrdinalIgnoreCase) || value.Equals("channel", StringComparison.OrdinalIgnoreCase) || value.Equals("room", StringComparison.OrdinalIgnoreCase); + private static string NormalizeClassification(string value) => value.Equals("room", StringComparison.OrdinalIgnoreCase) ? "group" : IsDirect(value) ? "direct" : value.ToLowerInvariant(); + private static string NormalizePeerKind(string value) => NormalizeClassification(value); + + private static SessionDisplayInfo Display(string title, string classification, string? agentId = null, string? channel = null, string? accountId = null, string? peerKind = null, bool isMain = false, bool isBackground = false) => new() + { + Title = title, TitleSource = "generated", Classification = classification, AgentId = NonEmpty(agentId), Channel = NonEmpty(channel), + AccountId = NonEmpty(accountId), PeerKind = NonEmpty(peerKind), IsMain = isMain, IsBackground = isBackground, + }; + + private static string? UsefulTitle(string key, string? value) + { + var normalized = NonEmpty(value); + return normalized is not null && !normalized.Equals(key, StringComparison.Ordinal) ? normalized : null; + } + + private static string? SafeLegacyDisplayName(string? value) + { + var normalized = NonEmpty(value); + return normalized is null || OpaqueIdRegex().IsMatch(normalized) ? null : normalized; + } + + private static string ReadableTail(string value) + { + var normalizedPath = value.Replace('\\', '/'); + var leaf = normalizedPath.Contains('/') ? normalizedPath.Split('/').LastOrDefault(part => part.Length > 0) ?? normalizedPath : normalizedPath; + var shortened = OpaqueIdRegex().Replace(leaf, match => $"…{match.Value[^4..]}"); + return NonEmpty(shortened.Length > 32 ? $"{shortened[..31]}…" : shortened) ?? "Session"; + } + + private static string? BuildSubtitle(string? channel, string? accountId, string? agentId, string? execNode, SessionWorktreeInfo? worktree) + { + var parts = new List(4); + if (FormatWorktree(worktree) is { } work) parts.Add(work); + if (NonEmpty(channel) is { } channelValue) parts.Add(ChannelLabel(channelValue)); + if (NonEmpty(accountId) is { } accountValue) parts.Add($"account {ReadableTail(accountValue)}"); + if (NonEmpty(agentId) is { } agentValue) parts.Add($"agent {ReadableTail(agentValue)}"); + if (NonEmpty(execNode) is { } nodeValue) parts.Add($"node {ReadableTail(nodeValue)}"); + return parts.Count > 0 ? string.Join(" · ", parts) : null; + } + + private static string? FormatWorktree(SessionWorktreeInfo? worktree) + { + if (worktree is null) return null; + var repo = NonEmpty(worktree.RepoRoot)?.Split('/', '\\').LastOrDefault(part => part.Length > 0); + var branch = NonEmpty(worktree.Branch); + if (branch?.StartsWith("openclaw/", StringComparison.Ordinal) == true) branch = branch["openclaw/".Length..]; + return (repo, branch) switch { ({ Length: > 0 }, { Length: > 0 }) => $"{repo} ⎇ {branch}", ({ Length: > 0 }, _) => repo, (_, { Length: > 0 }) => branch, _ => null }; + } + + private static string ChannelLabel(string channel) => channel.ToLowerInvariant() switch { "imessage" => "iMessage", "whatsapp" => "WhatsApp", "sms" => "SMS", _ => Capitalize(channel) }; + private static string Capitalize(string value) => value.Length > 0 ? char.ToUpperInvariant(value[0]) + value[1..] : value; + private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + [GeneratedRegex(@"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{10,}", RegexOptions.IgnoreCase)] + private static partial Regex OpaqueIdRegex(); +} + +public sealed class SessionDisplayInfo +{ + public string Title { get; init; } = ""; + public string TitleSource { get; init; } = "generated"; + public string? Subtitle { get; init; } + public string Classification { get; init; } = "custom"; + public string? AgentId { get; init; } + public string? Channel { get; init; } + public string? AccountId { get; init; } + public string? PeerKind { get; init; } + public bool IsMain { get; init; } + public bool IsBackground { get; init; } +} diff --git a/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs b/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs deleted file mode 100644 index ebb5d81b4..000000000 --- a/src/OpenClaw.Shared/Sessions/SessionPresentationResolver.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System.Text.RegularExpressions; - -namespace OpenClaw.Shared; - -/// -/// Resolves the Gateway's presentation contract and provides a conservative -/// fallback for older gateways without treating opaque key tails as a schema. -/// -public static partial class SessionPresentationResolver -{ - private static readonly HashSet BackgroundFamilies = new(StringComparer.OrdinalIgnoreCase) - { - "acp", "cron", "dreaming", "harness", "heartbeat", "hook", "subagent", "system", - }; - - public static SessionPresentationInfo Resolve(SessionInfo session) - { - ArgumentNullException.ThrowIfNull(session); - - var fallback = ResolveKey(session.Key, session.IsMain, session.Channel, session.Worktree); - var gateway = session.Presentation; - var label = UsefulTitle(session.Key, session.Label); - var gatewayTitle = UsefulTitle(session.Key, gateway?.Title); - var displayName = UsefulTitle(session.Key, SafeLegacyDisplayName(session.DisplayName)); - var derivedTitle = UsefulTitle(session.Key, session.DerivedTitle); - var worktreeTitle = UsefulTitle(session.Key, FormatWorktree(session.Worktree)); - var title = label ?? gatewayTitle ?? displayName ?? derivedTitle ?? worktreeTitle ?? fallback.Title; - var titleSource = label is not null ? "label" - : gatewayTitle is not null ? NonEmpty(gateway?.TitleSource) ?? "generated" - : displayName is not null ? "displayName" - : derivedTitle is not null ? "derivedTitle" - : worktreeTitle is not null ? "worktree" - : fallback.TitleSource; - var family = NonEmpty(gateway?.Family) ?? fallback.Family; - var agentId = NonEmpty(gateway?.AgentId) ?? fallback.AgentId; - var channel = NonEmpty(gateway?.Channel) ?? NonEmpty(session.Channel) ?? fallback.Channel; - var accountId = NonEmpty(gateway?.AccountId) ?? fallback.AccountId; - var peerKind = NonEmpty(gateway?.PeerKind) ?? fallback.PeerKind; - var subtitle = NonEmpty(gateway?.Subtitle) - ?? BuildSubtitle(channel, accountId, agentId, session.ExecNode, session.Worktree); - - return new SessionPresentationInfo - { - Title = title, - TitleSource = titleSource, - Subtitle = subtitle, - Family = family, - AgentId = agentId, - Channel = channel, - AccountId = accountId, - PeerKind = peerKind, - IsMain = session.IsMain, - IsBackground = gateway?.IsBackground ?? BackgroundFamilies.Contains(family), - }; - } - - public static bool IsBackground(SessionInfo session) => Resolve(session).IsBackground; - - public static bool IsVisible(SessionInfo session, bool showBackground) => - showBackground || !IsBackground(session); - - /// - /// Produces a bounded context label while masking opaque identifiers. - /// - public static string FormatContext(string value) - { - ArgumentNullException.ThrowIfNull(value); - return ReadableTail(value); - } - - private static SessionPresentationInfo ResolveKey( - string? rawKey, - bool isMain, - string? rowChannel, - SessionWorktreeInfo? worktree) - { - var key = rawKey?.Trim() ?? string.Empty; - if (key.Length == 0) - return isMain - ? Presentation("Main session", "main", agentId: "main", isMain: true) - : Presentation("Session", "unknown"); - if (key.Equals("global", StringComparison.OrdinalIgnoreCase)) - return Presentation("Global session", "global", agentId: isMain ? "main" : null, isMain: isMain); - if (key.Equals("unknown", StringComparison.OrdinalIgnoreCase)) - return Presentation("Unknown session", "unknown"); - - var (agentId, rest) = ParseAgentWrapper(key); - if (isMain) - return Presentation("Main session", "main", agentId: agentId, isMain: true); - - // Older gateways did not send the heartbeat base-session marker or a - // presentation object. Detect the terminal suffix before parsing any - // route so routed heartbeat keys do not become foreground chats. - if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase) - && rest.EndsWith(":heartbeat", StringComparison.OrdinalIgnoreCase)) - return Presentation("Heartbeat", "heartbeat", agentId, isBackground: true); - if (rest.Equals("subagent", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("subagent:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Subagent", "subagent", agentId, isBackground: true); - if (rest.Equals("acp", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("acp:", StringComparison.OrdinalIgnoreCase)) - return Presentation("ACP session", "acp", agentId, isBackground: true); - if (rest.Equals("cron", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("cron:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Scheduled task", "cron", agentId, isBackground: true); - if (rest.StartsWith("dashboard:", StringComparison.OrdinalIgnoreCase)) - return Presentation(FormatWorktree(worktree) ?? "New session", "dashboard", agentId); - if (rest.StartsWith("tui-", StringComparison.OrdinalIgnoreCase)) - return Presentation("Terminal session", "tui", agentId); - if (rest.StartsWith("explicit:", StringComparison.OrdinalIgnoreCase)) - return Presentation(ReadableTail(rest["explicit:".Length..]), "explicit", agentId); - if (rest.StartsWith("hook:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Hook run", "hook", agentId, isBackground: true); - if (rest.StartsWith("harness:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Harness session", "harness", agentId, isBackground: true); - if (rest.StartsWith("voice:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Voice call", "voice", agentId); - if (rest.StartsWith("dreaming-narrative-", StringComparison.OrdinalIgnoreCase)) - return Presentation("Dreaming", "dreaming", agentId, isBackground: true); - if (rest.Equals("boot", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("commitments:", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("internal-session-effects:", StringComparison.OrdinalIgnoreCase)) - return Presentation("Background task", "system", agentId, isBackground: true); - - var threadIndex = rest.LastIndexOf(":thread:", StringComparison.OrdinalIgnoreCase); - var routeRest = threadIndex >= 0 ? rest[..threadIndex] : rest; - var route = ParseRoute(routeRest, rowChannel); - if (threadIndex >= 0) - { - return Presentation( - route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} thread" : "Thread", - "thread", - agentId, - route.Channel, - route.AccountId, - route.PeerKind); - } - if (route.Family is not null) - { - var noun = route.Family switch - { - "direct" => "direct message", - "group" => "group", - _ => "channel", - }; - return Presentation( - route.Channel is { Length: > 0 } ? $"{ChannelLabel(route.Channel)} {noun}" : Capitalize(noun), - route.Family, - agentId, - route.Channel, - route.AccountId, - route.PeerKind); - } - - return Presentation(ReadableTail(rest), "custom", agentId); - } - - private static (string? AgentId, string Tail) ParseAgentWrapper(string key) - { - var first = key.IndexOf(':'); - var second = first >= 0 ? key.IndexOf(':', first + 1) : -1; - if (first <= 0 || second <= first + 1 || !key[..first].Equals("agent", StringComparison.OrdinalIgnoreCase)) - return (null, key); - return (key[(first + 1)..second], key[(second + 1)..]); - } - - private static (string? Family, string? Channel, string? AccountId, string? PeerKind) ParseRoute( - string rest, - string? rowChannel) - { - var parts = rest.Split(':'); - if (parts.Length >= 2 && IsDirect(parts[0])) - return ("direct", NonEmpty(rowChannel), null, "direct"); - if (parts.Length < 3) - return (null, null, null, null); - - var channel = NonEmpty(parts[0]); - if (IsPeerKind(parts[1])) - return (NormalizeFamily(parts[1]), channel, null, NormalizePeerKind(parts[1])); - if (parts.Length >= 4 && IsPeerKind(parts[2])) - return (NormalizeFamily(parts[2]), channel, NonEmpty(parts[1]), NormalizePeerKind(parts[2])); - return (null, null, null, null); - } - - private static bool IsDirect(string value) => - value.Equals("direct", StringComparison.OrdinalIgnoreCase) - || value.Equals("dm", StringComparison.OrdinalIgnoreCase); - - private static bool IsPeerKind(string value) => - IsDirect(value) - || value.Equals("group", StringComparison.OrdinalIgnoreCase) - || value.Equals("channel", StringComparison.OrdinalIgnoreCase) - || value.Equals("room", StringComparison.OrdinalIgnoreCase); - - private static string NormalizeFamily(string value) => - value.Equals("room", StringComparison.OrdinalIgnoreCase) ? "group" - : IsDirect(value) ? "direct" - : value.ToLowerInvariant(); - - private static string NormalizePeerKind(string value) => NormalizeFamily(value); - - private static SessionPresentationInfo Presentation( - string title, - string family, - string? agentId = null, - string? channel = null, - string? accountId = null, - string? peerKind = null, - bool isMain = false, - bool isBackground = false) => new() - { - Title = title, - TitleSource = "generated", - Family = family, - AgentId = NonEmpty(agentId), - Channel = NonEmpty(channel), - AccountId = NonEmpty(accountId), - PeerKind = NonEmpty(peerKind), - IsMain = isMain, - IsBackground = isBackground, - }; - - private static string? UsefulTitle(string key, string? value) - { - var normalized = NonEmpty(value); - return normalized is not null && !normalized.Equals(key, StringComparison.Ordinal) ? normalized : null; - } - - private static string? SafeLegacyDisplayName(string? value) - { - var normalized = NonEmpty(value); - return normalized is null || OpaqueIdRegex().IsMatch(normalized) ? null : normalized; - } - - private static string ReadableTail(string value) - { - var normalizedPath = value.Replace('\\', '/'); - var leaf = normalizedPath.Contains('/') - ? normalizedPath.Split('/').LastOrDefault(part => part.Length > 0) ?? normalizedPath - : normalizedPath; - var shortened = OpaqueIdRegex().Replace(leaf, match => $"…{match.Value[^4..]}"); - const int maxLength = 32; - if (shortened.Length > maxLength) - shortened = $"{shortened[..(maxLength - 1)]}…"; - return NonEmpty(shortened) ?? "Session"; - } - - private static string? BuildSubtitle( - string? channel, - string? accountId, - string? agentId, - string? execNode, - SessionWorktreeInfo? worktree) - { - var parts = new List(4); - var work = FormatWorktree(worktree); - if (work is not null) parts.Add(work); - if (NonEmpty(channel) is { } channelValue) parts.Add(ChannelLabel(channelValue)); - if (NonEmpty(accountId) is { } accountValue) parts.Add($"account {ReadableTail(accountValue)}"); - if (NonEmpty(agentId) is { } agentValue) parts.Add($"agent {ReadableTail(agentValue)}"); - if (NonEmpty(execNode) is { } nodeValue) parts.Add($"node {ReadableTail(nodeValue)}"); - return parts.Count > 0 ? string.Join(" · ", parts) : null; - } - - private static string? FormatWorktree(SessionWorktreeInfo? worktree) - { - if (worktree is null) return null; - var repo = NonEmpty(worktree.RepoRoot)?.Split('/', '\\').LastOrDefault(part => part.Length > 0); - var branch = NonEmpty(worktree.Branch); - if (branch?.StartsWith("openclaw/", StringComparison.Ordinal) == true) - branch = branch["openclaw/".Length..]; - return (repo, branch) switch - { - ({ Length: > 0 }, { Length: > 0 }) => $"{repo} ⎇ {branch}", - ({ Length: > 0 }, _) => repo, - (_, { Length: > 0 }) => branch, - _ => null, - }; - } - - private static string ChannelLabel(string channel) => channel.ToLowerInvariant() switch - { - "imessage" => "iMessage", - "whatsapp" => "WhatsApp", - "sms" => "SMS", - _ => Capitalize(channel), - }; - - private static string Capitalize(string value) => - value.Length > 0 ? char.ToUpperInvariant(value[0]) + value[1..] : value; - - private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - - [GeneratedRegex(@"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{10,}", RegexOptions.IgnoreCase)] - private static partial Regex OpaqueIdRegex(); -} diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index eeb155011..071bc228d 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -6165,7 +6165,7 @@ private ChatDataSnapshot BuildSnapshotLocked() var composeKey = _bridge.MainSessionKey; var composeAgentId = _sessions .FirstOrDefault(session => string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession - ? SessionPresentationResolver.Resolve(mainSession).AgentId ?? "main" + ? SessionDisplayResolver.Resolve(mainSession).AgentId ?? "main" : "main"; var composeReady = _bridge.HasHandshakeSnapshot && !string.IsNullOrWhiteSpace(composeKey) @@ -6319,13 +6319,13 @@ private bool TryGetSessionLocked(string threadId, out SessionInfo session) private static ChatThread ToThread(SessionInfo s, string title) { - var presentation = SessionPresentationResolver.Resolve(s); + var display = SessionDisplayResolver.Resolve(s); return new ChatThread { Id = s.Key ?? string.Empty, Title = title, - AgentId = presentation.AgentId, - IsBackground = presentation.IsBackground, + AgentId = display.AgentId, + IsBackground = display.IsBackground, Status = SessionVisibilityFilter.ToChatThreadStatus(s), Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working, Workspace = s.Channel, diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs index b9f472874..cc18a49ab 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs @@ -137,7 +137,7 @@ public void UpdateSessions(SessionInfo[] sessions) private IEnumerable SessionsForCurrentBackgroundScope() => (_allSessions ?? Array.Empty()) - .Where(session => SessionPresentationResolver.IsVisible(session, _showBackgroundSessions)); + .Where(session => SessionDisplayResolver.IsVisible(session, _showBackgroundSessions)); private void RebuildChannelTabs() { diff --git a/src/OpenClaw.Tray.WinUI/Services/SessionTitleFormatter.cs b/src/OpenClaw.Tray.WinUI/Services/SessionTitleFormatter.cs index 8bf4a9f9c..a595c0c5f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SessionTitleFormatter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SessionTitleFormatter.cs @@ -15,19 +15,19 @@ internal static void ConfigureLocalization(Func getLocalizedStri } /// - /// Formats a session title from the Gateway presentation contract, with a - /// bounded fallback for older gateways. + /// Formats a session title from flattened Gateway facts, with a bounded + /// fallback for older gateways. /// public static string Format(SessionInfo session) { ArgumentNullException.ThrowIfNull(session); - var presentation = SessionPresentationResolver.Resolve(session); - if (!presentation.TitleSource.Equals("generated", StringComparison.OrdinalIgnoreCase)) - return presentation.Title; + var display = SessionDisplayResolver.Resolve(session); + if (!display.TitleSource.Equals("generated", StringComparison.OrdinalIgnoreCase)) + return display.Title; - var family = presentation.Family.ToLowerInvariant(); - var hasChannel = !string.IsNullOrWhiteSpace(presentation.Channel); + var family = display.Classification.ToLowerInvariant(); + var hasChannel = !string.IsNullOrWhiteSpace(display.Channel); var resourceKey = family switch { "main" => "SessionTitle_Main", @@ -51,37 +51,37 @@ public static string Format(SessionInfo session) _ => null, }; if (resourceKey is null) - return presentation.Title; + return display.Title; return hasChannel && (family is "direct" or "group" or "channel" or "thread") - ? LocalizedFormat(resourceKey, presentation.Title, ChannelLabel(presentation.Channel)) - : Localized(resourceKey, presentation.Title); + ? LocalizedFormat(resourceKey, display.Title, ChannelLabel(display.Channel)) + : Localized(resourceKey, display.Title); } public static string? FormatSubtitle(SessionInfo session) { ArgumentNullException.ThrowIfNull(session); - var presentation = SessionPresentationResolver.Resolve(session); + var display = SessionDisplayResolver.Resolve(session); var parts = new List(5); if (FormatWorktree(session.Worktree) is { } worktree) parts.Add(worktree); - if (!string.IsNullOrWhiteSpace(presentation.Channel)) parts.Add(ChannelLabel(presentation.Channel)); - if (presentation.AccountId is { } accountId && !string.IsNullOrWhiteSpace(accountId)) + if (!string.IsNullOrWhiteSpace(display.Channel)) parts.Add(ChannelLabel(display.Channel)); + if (display.AccountId is { } accountId && !string.IsNullOrWhiteSpace(accountId)) { - var display = SessionPresentationResolver.FormatContext(accountId); - parts.Add(LocalizedFormat("SessionSubtitle_Account", $"account {display}", display)); + var context = SessionDisplayResolver.FormatContext(accountId); + parts.Add(LocalizedFormat("SessionSubtitle_Account", $"account {context}", context)); } - if (presentation.AgentId is { } agentId && !string.IsNullOrWhiteSpace(agentId)) + if (display.AgentId is { } agentId && !string.IsNullOrWhiteSpace(agentId)) { - var display = SessionPresentationResolver.FormatContext(agentId); - parts.Add(LocalizedFormat("SessionSubtitle_Agent", $"agent {display}", display)); + var context = SessionDisplayResolver.FormatContext(agentId); + parts.Add(LocalizedFormat("SessionSubtitle_Agent", $"agent {context}", context)); } if (session.ExecNode is { } execNode && !string.IsNullOrWhiteSpace(execNode)) { - var display = SessionPresentationResolver.FormatContext(execNode); - parts.Add(LocalizedFormat("SessionSubtitle_Node", $"node {display}", display)); + var context = SessionDisplayResolver.FormatContext(execNode); + parts.Add(LocalizedFormat("SessionSubtitle_Node", $"node {context}", context)); } - return parts.Count > 0 ? string.Join(" · ", parts) : presentation.Subtitle; + return parts.Count > 0 ? string.Join(" · ", parts) : display.Subtitle; } /// @@ -173,7 +173,7 @@ public static string Format(SessionInfo session, IReadOnlyList sess private static bool IsCanonicalMain(SessionInfo session) { - return SessionPresentationResolver.Resolve(session).IsMain; + return SessionDisplayResolver.Resolve(session).IsMain; } private static string Localized(string key, string fallback) diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index 0d772dc65..dbf3136b0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -209,7 +209,7 @@ internal static long SessionUsedTokens(SessionInfo session) => return null; var foregroundSessions = sessions - .Where(session => !SessionPresentationResolver.IsBackground(session)) + .Where(session => !SessionDisplayResolver.IsBackground(session)) .ToArray(); if (foregroundSessions.Length == 0) return null; diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index 63511ec2a..16fe5f30f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -288,7 +288,7 @@ or OpenClaw.Connection.OverallConnectionState.Degraded // ── Sessions (now below Devices) ── var foregroundSessions = _snapshot.Sessions - .Where(session => !SessionPresentationResolver.IsBackground(session)) + .Where(session => !SessionDisplayResolver.IsBackground(session)) .ToArray(); if (foregroundSessions.Length > 0) { diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index 3fb021718..518482ada 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -679,25 +679,17 @@ public void SessionInfo_EmptyStatus_DoesNotThrow() } [Fact] - public void Clone_DeepCopies_Presentation() + public void Clone_PreservesFlatSessionFacts() { var original = new SessionInfo { Key = "agent:main:test", - Presentation = new SessionPresentationInfo - { - Title = "Original", Family = "custom", AgentId = "main", IsBackground = false, - }, + Classification = "custom", AgentId = "main", IsBackground = false, }; var clone = original.Clone(); - - // Mutate clone's Presentation - clone.Presentation!.Title = "Mutated"; - clone.Presentation.IsBackground = true; - - // Original must be unchanged - Assert.Equal("Original", original.Presentation.Title); - Assert.False(original.Presentation.IsBackground); + Assert.Equal("custom", clone.Classification); + Assert.Equal("main", clone.AgentId); + Assert.False(clone.IsBackground); } [Fact] @@ -723,40 +715,6 @@ public void Clone_DeepCopies_Worktree() } [Fact] - public void Clone_NullPresentation_DoesNotThrow() - { - var original = new SessionInfo { Key = "test", Presentation = null, Worktree = null }; - var clone = original.Clone(); - Assert.Null(clone.Presentation); - Assert.Null(clone.Worktree); - } - - [Fact] - public void Clone_SnapshotIsolation_MultipleClonesIndependent() - { - var live = new SessionInfo - { - Key = "agent:main:explicit:task", - Presentation = new SessionPresentationInfo { Title = "V1", Family = "explicit" }, - Worktree = new SessionWorktreeInfo { Branch = "main" }, - }; - - var snapshot1 = live.Clone(); - live.Presentation.Title = "V2"; - live.Worktree.Branch = "develop"; - var snapshot2 = live.Clone(); - - // snapshot1 sees V1, snapshot2 sees V2, both independent - Assert.Equal("V1", snapshot1.Presentation!.Title); - Assert.Equal("main", snapshot1.Worktree!.Branch); - Assert.Equal("V2", snapshot2.Presentation!.Title); - Assert.Equal("develop", snapshot2.Worktree!.Branch); - - // Mutating snapshot2 doesn't affect live or snapshot1 - snapshot2.Presentation.Title = "V3"; - Assert.Equal("V2", live.Presentation.Title); - Assert.Equal("V1", snapshot1.Presentation.Title); - } } public class GatewayUsageInfoTests diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 456e0cf4d..2d0278433 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -1926,7 +1926,7 @@ public void GetSessionList_SortsMainSessionFirst() } [Fact] - public void ParseSessions_ProjectsGatewayPresentationContract() + public void ParseSessions_ProjectsFlattenedSessionFacts() { var helper = new GatewayClientTestHelper(); helper.ParseSessionsPayload(""" @@ -1946,18 +1946,12 @@ public void ParseSessions_ProjectsGatewayPresentationContract() "execNode": "windows-dev", "parentSessionKey": "agent:main:main", "spawnDepth": 1, - "presentation": { - "title": "Family chat", - "titleSource": "label", - "subtitle": "Telegram · account main · agent main", - "family": "direct", - "agentId": "main", - "channel": "telegram", - "accountId": "main", - "peerKind": "direct", - "isMain": false, - "isBackground": false - } + "classification": "direct", + "agentId": "main", + "accountId": "main", + "peerKind": "direct", + "isMain": false, + "isBackground": false } ] """); @@ -1974,10 +1968,10 @@ public void ParseSessions_ProjectsGatewayPresentationContract() Assert.Equal("agent:main:main", session.ParentSessionKey); Assert.Equal(1, session.SpawnDepth); Assert.False(session.IsMain); - Assert.Equal("Family chat", session.Presentation?.Title); - Assert.Equal("label", session.Presentation?.TitleSource); - Assert.Equal("direct", session.Presentation?.Family); - Assert.Equal("main", session.Presentation?.AccountId); + Assert.Equal("direct", session.Classification); + Assert.Equal("main", session.AgentId); + Assert.Equal("main", session.AccountId); + Assert.Equal("direct", session.PeerKind); } [Fact] @@ -1990,24 +1984,12 @@ public void ParseSessions_UsesHandshakeMainSessionKeyInsteadOfKeyShapeGuessing() { "key": "agent:main:main", "displayName": "Named non-main session", - "presentation": { - "title": "Named non-main session", - "titleSource": "displayName", - "family": "custom", - "isMain": true, - "isBackground": false - } + "isMain": true }, { "key": "global", "displayName": "Global main", - "presentation": { - "title": "Global session", - "titleSource": "generated", - "family": "global", - "isMain": false, - "isBackground": false - } + "isMain": false } ] """); @@ -2020,23 +2002,17 @@ public void ParseSessions_UsesHandshakeMainSessionKeyInsteadOfKeyShapeGuessing() [Fact] public void ParseSessions_LegacyHandshakeAliasUsesRowMetadataAndBoundedCanonicalFallback() { - var withPresentation = new GatewayClientTestHelper(); - withPresentation.SetMainSessionKey("main", isCanonical: false); - withPresentation.ParseSessionsPayload(""" + var withRowFacts = new GatewayClientTestHelper(); + withRowFacts.SetMainSessionKey("main", isCanonical: false); + withRowFacts.ParseSessionsPayload(""" [ { "key": "agent:main:main", - "presentation": { - "title": "Main session", - "titleSource": "generated", - "family": "main", - "isMain": true, - "isBackground": false - } + "isMain": true } ] """); - Assert.True(Assert.Single(withPresentation.GetSessionList()).IsMain); + Assert.True(Assert.Single(withRowFacts.GetSessionList()).IsMain); var withoutPresentation = new GatewayClientTestHelper(); withoutPresentation.SetMainSessionKey("main", isCanonical: false); @@ -2047,21 +2023,14 @@ public void ParseSessions_LegacyHandshakeAliasUsesRowMetadataAndBoundedCanonical } [Fact] - public void ParseSessions_UsesPresentationMainBeforeHandshakeAuthority() + public void ParseSessions_UsesRowMainBeforeHandshakeAuthority() { var helper = new GatewayClientTestHelper(); helper.ParseSessionsPayload(""" [ { "key": "global", - "isMain": false, - "presentation": { - "title": "Global session", - "titleSource": "generated", - "family": "global", - "isMain": true, - "isBackground": false - } + "isMain": true } ] """); @@ -2070,24 +2039,20 @@ public void ParseSessions_UsesPresentationMainBeforeHandshakeAuthority() } [Fact] - public void ParseSessions_RejectsPartialPresentationObjects() + public void ParseSessions_FallsBackWhenFlatFactsAreAbsent() { var helper = new GatewayClientTestHelper(); helper.ParseSessionsPayload(""" [ { "key": "agent:main:subagent:child", - "presentation": { - "title": "Subagent", - "family": "subagent" - } + "status": "active" } ] """); var session = Assert.Single(helper.GetSessionList()); - Assert.Null(session.Presentation); - Assert.True(SessionPresentationResolver.IsBackground(session)); + Assert.True(SessionDisplayResolver.IsBackground(session)); } [Fact] @@ -2119,13 +2084,7 @@ public void ParseSessions_SparseUpdatesPreserveExplicitFalseMainStatus() [ { "key": "agent:main:main", - "presentation": { - "title": "Not main", - "titleSource": "label", - "family": "custom", - "isMain": false, - "isBackground": false - } + "isMain": false }, { "key": "main", "isMain": false } ] @@ -2141,7 +2100,7 @@ public void ParseSessions_SparseUpdatesPreserveExplicitFalseMainStatus() } [Fact] - public void ParseSessions_SparseUpdatesPreservePresentationMetadata() + public void ParseSessions_SparseUpdatesPreserveFlattenedFacts() { var helper = new GatewayClientTestHelper(); helper.ParseSessionsPayload(""" @@ -2149,14 +2108,10 @@ public void ParseSessions_SparseUpdatesPreservePresentationMetadata() { "key": "agent:main:subagent:child", "channel": "telegram", - "presentation": { - "title": "Research", - "titleSource": "label", - "family": "subagent", - "agentId": "main", - "isMain": false, - "isBackground": true - } + "classification": "subagent", + "agentId": "main", + "isMain": false, + "isBackground": true } ] """); @@ -2166,8 +2121,9 @@ public void ParseSessions_SparseUpdatesPreservePresentationMetadata() var session = Assert.Single(helper.GetSessionList()); Assert.Equal("telegram", session.Channel); - Assert.Equal("Research", session.Presentation?.Title); - Assert.True(session.Presentation?.IsBackground == true); + Assert.Equal("subagent", session.Classification); + Assert.Equal("main", session.AgentId); + Assert.True(session.IsBackground == true); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json b/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json index a36409fb8..fc4d6ec39 100644 --- a/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json +++ b/tests/OpenClaw.Shared.Tests/Protocol/gateway-protocol-snapshot.json @@ -69,7 +69,7 @@ "key", "isMain", "status", "model", "label", "channel", "displayName", "derivedTitle", "modelProvider", "provider", "subject", "groupChannel", "room", "space", "chatType", "execNode", "parentSessionKey", "spawnDepth", "origin", - "worktree", "presentation", "sessionId", "thinkingLevel", "verboseLevel", + "worktree", "classification", "agentId", "accountId", "peerKind", "isMain", "isBackground", "sessionId", "thinkingLevel", "verboseLevel", "systemSent", "abortedLastRun", "inputTokens", "outputTokens", "totalTokens", "contextTokens", "updatedAt", "startedAt" ] diff --git a/tests/OpenClaw.Shared.Tests/SessionDisplayResolverTests.cs b/tests/OpenClaw.Shared.Tests/SessionDisplayResolverTests.cs new file mode 100644 index 000000000..1d595d55b --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SessionDisplayResolverTests.cs @@ -0,0 +1,45 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public sealed class SessionDisplayResolverTests +{ + [Fact] + public void Resolve_UsesFlatFactsAndDoesNotExposeDirectPeerDisplayName() + { + var resolved = SessionDisplayResolver.Resolve(new SessionInfo + { + Key = "agent:main:telegram:main:direct:491234567890", + DisplayName = "Telegram:491234567890", + Classification = "direct", + AgentId = "main", + AccountId = "main", + PeerKind = "direct", + }); + + Assert.Equal("Telegram direct message", resolved.Title); + Assert.Equal("direct", resolved.Classification); + Assert.Equal("main", resolved.AccountId); + Assert.DoesNotContain("491234567890", resolved.Title, StringComparison.Ordinal); + } + + [Theory] + [InlineData("agent:main:main", true, "main", "Main session", false)] + [InlineData("agent:main:subagent:child", false, "subagent", "Subagent", true)] + [InlineData("agent:main:cron:job", false, "cron", "Scheduled task", true)] + public void Resolve_FallsBackForOlderGateways(string key, bool isMain, string classification, string title, bool isBackground) + { + var resolved = SessionDisplayResolver.Resolve(new SessionInfo { Key = key, IsMain = isMain }); + Assert.Equal(classification, resolved.Classification); + Assert.Equal(title, resolved.Title); + Assert.Equal(isBackground, resolved.IsBackground); + } + + [Fact] + public void IsVisible_HidesBackgroundUnlessUserEnablesIt() + { + var session = new SessionInfo { Key = "agent:main:subagent:child", Classification = "subagent", IsBackground = true }; + Assert.False(SessionDisplayResolver.IsVisible(session, showBackground: false)); + Assert.True(SessionDisplayResolver.IsVisible(session, showBackground: true)); + } +} diff --git a/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs b/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs deleted file mode 100644 index 3c79c0a17..000000000 --- a/tests/OpenClaw.Shared.Tests/SessionPresentationResolverTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using OpenClaw.Shared; - -namespace OpenClaw.Shared.Tests; - -public sealed class SessionPresentationResolverTests -{ - [Fact] - public void Resolve_PrefersGatewayTitleOverLegacyDisplayName() - { - var presentation = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:main:telegram:main:direct:491234567890", - DisplayName = "Telegram:491234567890", - Presentation = new SessionPresentationInfo - { - Title = "Family chat", - Family = "direct", - }, - }); - - Assert.Equal("Family chat", presentation.Title); - Assert.DoesNotContain("491234567890", presentation.Title, StringComparison.Ordinal); - } - - [Fact] - public void Resolve_PrefersGatewayPresentationAndProjectsContext() - { - var session = new SessionInfo - { - Key = "agent:main:telegram:main:direct:491234567890", - DisplayName = "agent:main:telegram:main:direct:491234567890", - Presentation = new SessionPresentationInfo - { - Title = "Telegram direct message", - Subtitle = "Telegram · account main · agent main", - Family = "direct", - AgentId = "main", - Channel = "telegram", - AccountId = "main", - PeerKind = "direct", - }, - }; - - var resolved = SessionPresentationResolver.Resolve(session); - - Assert.Equal("Telegram direct message", resolved.Title); - Assert.Equal("Telegram · account main · agent main", resolved.Subtitle); - Assert.Equal("direct", resolved.Family); - Assert.Equal("main", resolved.AgentId); - Assert.Equal("telegram", resolved.Channel); - Assert.Equal("main", resolved.AccountId); - Assert.DoesNotContain("491234567890", resolved.Title); - } - - [Theory] - [InlineData("agent:main:main", true, "main", "Main session", false)] - [InlineData("agent:main:dashboard:01234567-89ab-cdef-0123-456789abcdef", false, "dashboard", "New session", false)] - [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef", false, "tui", "Terminal session", false)] - [InlineData("agent:main:tui-01234567-89ab-cdef-0123-456789abcdef:heartbeat", false, "heartbeat", "Heartbeat", true)] - [InlineData("agent:main:subagent:child", false, "subagent", "Subagent", true)] - [InlineData("agent:main:acp:child", false, "acp", "ACP session", true)] - [InlineData("agent:main:cron:job:run:run-1", false, "cron", "Scheduled task", true)] - [InlineData("agent:main:cron:group:run:run-1", false, "cron", "Scheduled task", true)] - [InlineData("agent:main:dreaming-narrative-rem-workspace", false, "dreaming", "Dreaming", true)] - [InlineData("agent:main:internal-session-effects:run", false, "system", "Background task", true)] - public void Resolve_FallsBackForOlderGateways( - string key, - bool isMain, - string family, - string title, - bool isBackground) - { - var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = key, IsMain = isMain }); - - Assert.Equal(family, resolved.Family); - Assert.Equal(title, resolved.Title); - Assert.Equal(isBackground, resolved.IsBackground); - } - - [Fact] - public void Resolve_TreatsOnlyTheAgentWrapperAsStructured() - { - var resolved = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:ops:explicit:model-run-01234567-89ab-cdef-0123-456789abcdef", - }); - - Assert.Equal("model-run-…cdef", resolved.Title); - Assert.Equal("ops", resolved.AgentId); - Assert.DoesNotContain("01234567-89ab-cdef-0123-456789abcdef", resolved.Title); - } - - [Theory] - [InlineData("agent:ops:discord:work:group:dev", "group")] - [InlineData("agent:ops:discord:work:channel:dev", "channel")] - [InlineData("agent:ops:discord:work:room:dev", "group")] - public void Resolve_RecognizesAccountQualifiedGroupRoutes(string key, string family) - { - var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = key }); - - Assert.Equal(family, resolved.Family); - Assert.Equal("discord", resolved.Channel); - Assert.Equal("work", resolved.AccountId); - } - - [Fact] - public void Resolve_RejectsRawKeyDisplayNames() - { - const string key = "agent:main:telegram:main:direct:491234567890"; - var resolved = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = key, - DisplayName = key, - }); - - Assert.Equal("Telegram direct message", resolved.Title); - Assert.DoesNotContain("491234567890", resolved.Title); - } - - [Fact] - public void Resolve_DoesNotGuessHeartbeatFromAmbiguousSuffixes() - { - var explicitSession = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:ops:explicit:heartbeat", - }); - var routedSession = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:ops:telegram:group:heartbeat", - }); - - Assert.Equal("explicit", explicitSession.Family); - Assert.False(explicitSession.IsBackground); - Assert.Equal("group", routedSession.Family); - Assert.False(routedSession.IsBackground); - - var explicitThread = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:ops:explicit:thread:launch", - }); - Assert.Equal("explicit", explicitThread.Family); - } - - [Fact] - public void Resolve_RejectsLegacyDisplayNamesContainingOpaqueIds() - { - var resolved = SessionPresentationResolver.Resolve(new SessionInfo - { - Key = "agent:main:telegram:main:direct:491234567890", - DisplayName = "Telegram:491234567890", - }); - - Assert.Equal("Telegram direct message", resolved.Title); - Assert.DoesNotContain("491234567890", resolved.Title); - } - - [Fact] - public void Resolve_AssignsCanonicalGlobalSessionToMainAgent() - { - var resolved = SessionPresentationResolver.Resolve(new SessionInfo { Key = "global", IsMain = true }); - - Assert.Equal("main", resolved.AgentId); - Assert.True(resolved.IsMain); - } - - [Fact] - public void IsVisible_HidesBackgroundUnlessUserEnablesIt() - { - var session = new SessionInfo - { - Key = "agent:main:subagent:child", - Presentation = new SessionPresentationInfo - { - Title = "Research", - Family = "subagent", - IsBackground = true, - }, - }; - - Assert.False(SessionPresentationResolver.IsVisible(session, showBackground: false)); - Assert.True(SessionPresentationResolver.IsVisible(session, showBackground: true)); - } -} diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index e3d294594..d0b378c5e 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -1534,7 +1534,7 @@ public async Task LoadAsync_DistinguishesDuplicateMultiSegmentSessionTitles() } [Fact] - public async Task LoadAsync_PreservesRawKeyAsId_EvenWithPresentationTitle() + public async Task LoadAsync_PreservesRawKeyAsIdWithFlattenedClassification() { // Gateway keys must round-trip untouched: the resolver only derives display fields. var rawKey = "agent:main:tui-847241c7-3f9a-4a2b-b123-abcdef123456"; @@ -1543,12 +1543,8 @@ public async Task LoadAsync_PreservesRawKeyAsId_EvenWithPresentationTitle() new SessionInfo { Key = rawKey, - Presentation = new SessionPresentationInfo - { - Title = "Terminal session", - Family = "tui", - AgentId = "main", - }, + Classification = "tui", + AgentId = "main", }, }; var (_, provider, _, _) = CreateProvider(sessions); @@ -1591,18 +1587,16 @@ public async Task LoadAsync_EndedAndBackgroundFiltering_ComposesCorrectly() } [Fact] - public async Task LoadAsync_GatewayPresentationAgentId_OverridesKeyParsing() + public async Task LoadAsync_FlatAgentId_OverridesKeyParsing() { - // When Presentation.AgentId is set, it takes precedence over key parsing. + // A Gateway-provided agent id takes precedence over key parsing. var sessions = new[] { new SessionInfo { Key = "agent:main:explicit:work", - Presentation = new SessionPresentationInfo - { - Title = "Work", Family = "explicit", AgentId = "custom-agent", - }, + Classification = "explicit", + AgentId = "custom-agent", }, }; var (_, provider, _, _) = CreateProvider(sessions); diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 03a536c5b..f83c06ce5 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -716,12 +716,8 @@ public void SelectActiveSession_IgnoresBackgroundSessions() { Key = "agent:main:tui-id:heartbeat", Status = "active", - Presentation = new SessionPresentationInfo - { - Title = "Heartbeat", - Family = "heartbeat", - IsBackground = true, - }, + Classification = "heartbeat", + IsBackground = true, }; var main = new SessionInfo { Key = "agent:main:main", IsMain = true, Status = "idle" }; diff --git a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs index b776a1e66..4aad2c07f 100644 --- a/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs +++ b/tests/OpenClaw.Tray.Tests/SessionTitleFormatterTests.cs @@ -134,18 +134,14 @@ public void Format_DoesNotExposeOpaqueSessionKeys(string key, string expected) } [Fact] - public void Format_PreservesExplicitGatewayTitlesAndLocalizesOnlyGeneratedFamilies() + public void Format_PreservesExplicitLabelsAndLocalizesGeneratedClassifications() { var explicitTitle = new SessionInfo { Key = "agent:main:telegram:main:direct:491234567890", - Presentation = new SessionPresentationInfo - { - Title = "Family chat", - TitleSource = "label", - Family = "direct", - Channel = "telegram", - }, + Label = "Family chat", + Classification = "direct", + Channel = "telegram", }; Assert.Equal("Family chat", SessionTitleFormatter.Format(explicitTitle)); @@ -157,33 +153,23 @@ public void Format_HandlesCaseInsensitiveGeneratedRouteFamilies() var session = new SessionInfo { Key = "route", - Presentation = new SessionPresentationInfo - { - Title = "Telegram direct message", - TitleSource = "generated", - Family = "Direct", - Channel = "telegram", - }, + Classification = "Direct", + Channel = "telegram", }; Assert.Equal("Telegram direct message", SessionTitleFormatter.Format(session)); } [Fact] - public void FormatSubtitle_PreservesGatewaySubtitleWithoutStructuredParts() + public void FormatSubtitle_UsesLocalFallbackWithoutStructuredParts() { var session = new SessionInfo { Key = "custom", - Presentation = new SessionPresentationInfo - { - Title = "Custom", - Family = "custom", - Subtitle = "Remote workspace", - }, + DerivedTitle = "Custom", }; - Assert.Equal("Remote workspace", SessionTitleFormatter.FormatSubtitle(session)); + Assert.Null(SessionTitleFormatter.FormatSubtitle(session)); } [Fact] @@ -192,12 +178,8 @@ public void FormatSubtitle_MasksPhoneLikeAccountIds() var session = new SessionInfo { Key = "custom", - Presentation = new SessionPresentationInfo - { - Title = "Telegram direct message", - Family = "direct", - AccountId = "491234567890", - }, + Classification = "direct", + AccountId = "491234567890", }; var subtitle = SessionTitleFormatter.FormatSubtitle(session); @@ -244,7 +226,7 @@ public void SessionsPage_UsesSharedSessionTitleFormatter() Assert.Contains("DisplayName = displayName", source, StringComparison.Ordinal); Assert.Contains("Key = s.Key", source, StringComparison.Ordinal); Assert.Contains("SessionsForCurrentBackgroundScope()", source, StringComparison.Ordinal); - Assert.Contains("SessionPresentationResolver.IsVisible(session, _showBackgroundSessions)", source, StringComparison.Ordinal); + Assert.Contains("SessionDisplayResolver.IsVisible(session, _showBackgroundSessions)", source, StringComparison.Ordinal); var xaml = File.ReadAllText(Path.Combine( TestRepositoryPaths.GetRepositoryRoot(), From 8743bb5fcaa10db3d23a84e8a005e3be4216fb95 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 30 Jul 2026 16:42:40 +0800 Subject: [PATCH 2/3] test(sessions): fix flattened facts test syntax --- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index 518482ada..da50c1e94 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -713,8 +713,6 @@ public void Clone_DeepCopies_Worktree() Assert.Equal("main", original.Worktree.Branch); Assert.Equal("/home/user/repo", original.Worktree.RepoRoot); } - - [Fact] } public class GatewayUsageInfoTests From 9e2b337f4ce75a13164aca66cedad8333d6d5cd3 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 30 Jul 2026 16:53:56 +0800 Subject: [PATCH 3/3] fix(sessions): preserve legacy display fallback --- src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs b/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs index 9d1090088..3268db924 100644 --- a/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs +++ b/src/OpenClaw.Shared/Sessions/SessionDisplayResolver.cs @@ -15,7 +15,8 @@ public static SessionDisplayInfo Resolve(SessionInfo session) ArgumentNullException.ThrowIfNull(session); var fallback = ResolveKey(session.Key, session.IsMain, session.Channel, session.Worktree); - var classification = NonEmpty(session.Classification) ?? fallback.Classification; + var gatewayClassification = NonEmpty(session.Classification); + var classification = gatewayClassification ?? fallback.Classification; var agentId = NonEmpty(session.AgentId) ?? fallback.AgentId; var channel = NonEmpty(session.Channel) ?? fallback.Channel; var accountId = NonEmpty(session.AccountId) ?? fallback.AccountId; @@ -25,7 +26,9 @@ public static SessionDisplayInfo Resolve(SessionInfo session) var title = UsefulTitle(session.Key, session.Label) ?? (isDirect ? null : SafeLegacyDisplayName(session.DisplayName)) ?? UsefulTitle(session.Key, session.DerivedTitle) - ?? TitleForClassification(classification, channel, session.Worktree, fallback.Title); + ?? (gatewayClassification is null + ? fallback.Title + : TitleForClassification(classification, channel, session.Worktree, fallback.Title)); return new SessionDisplayInfo {