diff --git a/src/OpenClaw.Chat/ChatModelChoice.cs b/src/OpenClaw.Chat/ChatModelChoice.cs new file mode 100644 index 000000000..a5a10964b --- /dev/null +++ b/src/OpenClaw.Chat/ChatModelChoice.cs @@ -0,0 +1,243 @@ +namespace OpenClaw.Chat; + +using OpenClaw.Shared; + +/// +/// Provider-rich description of a model exposed by models.list. +/// +/// Wire model id (e.g. claude-opus-4.8). +/// Human-friendly label (falls back to ). +/// Owning provider (e.g. OpenAI, Anthropic), when known. +/// Context-window size in tokens, when known. +/// True when the provider is configured on the gateway. +/// +/// True when the model can be selected right now. When false the picker shows it +/// but does not let the user switch to it. +/// +/// +/// True when the model's provider still needs authentication/credentials before +/// the model is usable. +/// +/// True when the gateway marks this model as the default. +public sealed record ChatModelChoice( + string Id, + string DisplayName, + string? Provider = null, + int? ContextWindow = null, + bool IsConfigured = true, + bool IsAvailable = true, + bool RequiresAuth = false, + bool IsDefault = false) +{ + /// + /// Provider-qualified identity used for picker tags and sessions.patch + /// model refs. Already-qualified ids are preserved. + /// + public string SelectionId => BuildSelectionId(Id, Provider); + + /// + /// True when the user may switch the session to this model. Auth-needed + /// models remain selectable (selecting one routes the user toward the + /// gateway's provider-auth flow); only explicitly unavailable models are + /// blocked. + /// + public bool IsSelectable => IsAvailable; + + /// + /// Maps gateway models into ordered, selection-deduplicated picker entries. + /// + public static IReadOnlyList FromModelsList(ModelsListInfo? info) + { + if (info?.Models is not { Count: > 0 }) return Array.Empty(); + + var seen = new HashSet(StringComparer.Ordinal); + var list = new List(info.Models.Count); + foreach (var m in info.Models) + { + if (m is null || string.IsNullOrEmpty(m.Id)) continue; + // Hide explicitly unconfigured models unless the gateway reports an + // auth flow for them; auth-needed rows are useful picker actions. + if (m.HasConfiguredFlag && !m.IsConfigured && !m.RequiresAuth) continue; + var choice = new ChatModelChoice( + Id: m.Id, + DisplayName: m.DisplayName, + Provider: m.Provider, + ContextWindow: m.ContextWindow, + IsConfigured: m.IsConfigured, + IsAvailable: m.IsAvailable, + RequiresAuth: m.RequiresAuth, + IsDefault: m.IsDefault); + if (!seen.Add(choice.SelectionId)) continue; + list.Add(choice); + } + return list; + } + + public bool MatchesModel(string? modelId, string? provider = null) + { + var normalizedModel = NormalizeId(modelId); + if (normalizedModel is null) return false; + + if (NormalizeId(provider) is { } normalizedProvider) + { + var providerQualified = BuildSelectionId(normalizedModel, normalizedProvider); + return string.Equals(SelectionId, providerQualified, StringComparison.Ordinal) + || (string.Equals(Id, normalizedModel, StringComparison.Ordinal) + && string.Equals(NormalizeId(Provider), normalizedProvider, StringComparison.OrdinalIgnoreCase)); + } + + return string.Equals(Id, normalizedModel, StringComparison.Ordinal) + || string.Equals(SelectionId, normalizedModel, StringComparison.Ordinal); + } + + public static string BuildSelectionId(string modelId, string? provider) + { + var normalizedModel = NormalizeId(modelId) ?? string.Empty; + if (normalizedModel.Length == 0) return string.Empty; + var normalizedProvider = NormalizeId(provider); + if (normalizedProvider is null) return normalizedModel; + + var prefix = normalizedProvider + "/"; + return normalizedModel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? normalizedModel + : $"{normalizedProvider}/{normalizedModel}"; + } + + public static string? ResolveSelectionId( + string? modelId, + string? provider, + IReadOnlyList choices) + { + var normalizedModel = NormalizeId(modelId); + if (normalizedModel is null) return null; + + if (NormalizeId(provider) is not null) + { + var match = choices.FirstOrDefault(c => c.MatchesModel(normalizedModel, provider)); + if (match is not null) return match.SelectionId; + + var bareRawMatches = choices + .Where(c => c.Id == normalizedModel && string.IsNullOrWhiteSpace(c.Provider)) + .Take(2) + .ToArray(); + if (bareRawMatches.Length == 1) return bareRawMatches[0].SelectionId; + + return BuildSelectionId(normalizedModel, provider); + } + + var direct = choices.FirstOrDefault(c => c.SelectionId == normalizedModel); + if (direct is not null) return direct.SelectionId; + + var rawMatches = choices.Where(c => c.Id == normalizedModel).Take(2).ToArray(); + return rawMatches.Length == 1 ? rawMatches[0].SelectionId : normalizedModel; + } + + private static string? NormalizeId(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } +} + +/// +/// Pure label/formatting helpers for the model picker. Lives in +/// OpenClaw.Chat (no WinUI dependency) so the display strings can be unit +/// tested without spinning up the composer. +/// +public static class ChatModelLabels +{ + /// + /// True when represents "no explicit model + /// override" — i.e. the session is tracking the gateway/agent default. + /// This predicate only describes the current state, derived from an + /// empty/absent session model. Clearing an override (so a session tracks the + /// default again) is performed via the tri-state SessionPatch.Clear + /// (explicit JSON null), not by sending an empty model string. + /// + public static bool IsTrackingDefault(string? modelId) => string.IsNullOrEmpty(modelId); + + /// + /// Compact token-count label: 272000 → "272K", 1_048_576 → "1M", + /// 200000 → "200K". Falls back to the raw number for small values. + /// + public static string FormatContextWindow(int contextWindow) + { + if (contextWindow <= 0) return string.Empty; + if (contextWindow >= 1_000_000) + { + var millions = contextWindow / 1_000_000.0; + // Trim a trailing ".0" so 2_000_000 → "2M" not "2.0M". + return millions == Math.Floor(millions) + ? $"{(int)millions}M" + : $"{millions:0.#}M"; + } + if (contextWindow >= 1_000) + { + var thousands = contextWindow / 1_000.0; + return thousands == Math.Floor(thousands) + ? $"{(int)thousands}K" + : $"{thousands:0.#}K"; + } + return contextWindow.ToString(); + } + + /// + /// Builds the secondary metadata segment ("OpenAI · 272K") from provider and + /// context window, or an empty string when neither is known. + /// + public static string BuildMetaSegment(ChatModelChoice choice) + { + var hasProvider = !string.IsNullOrWhiteSpace(choice.Provider); + var ctx = choice.ContextWindow is { } cw ? FormatContextWindow(cw) : string.Empty; + var hasCtx = ctx.Length > 0; + + if (hasProvider && hasCtx) return $"{choice.Provider} · {ctx}"; + if (hasProvider) return choice.Provider!; + if (hasCtx) return ctx; + return string.Empty; + } + + /// + /// Trailing state marker for a model: "default", "auth needed", + /// "unavailable", or empty. Unavailable takes precedence over auth-needed, + /// which takes precedence over default. Only explicit gateway signals drive + /// the markers — a missing flag is + /// not treated as "auth needed" because the gateway's configured view + /// often omits the field entirely. + /// + public static string BuildStateMarker(ChatModelChoice choice) + { + if (!choice.IsAvailable) return "unavailable"; + if (choice.RequiresAuth) return "auth needed"; + if (choice.IsDefault) return "default"; + return string.Empty; + } + + /// + /// Full menu/combo label, e.g. "Claude Opus 4.8 · Anthropic · 200K · default". + /// State marker is appended last so default/auth-needed/unavailable reads at + /// the end of the row. + /// + public static string BuildMenuLabel(ChatModelChoice choice) + { + var label = choice.DisplayName; + var meta = BuildMetaSegment(choice); + if (meta.Length > 0) label = $"{label} · {meta}"; + var marker = BuildStateMarker(choice); + if (marker.Length > 0) label = $"{label} · {marker}"; + return label; + } + + /// + /// Label for the "clear to gateway default" picker entry. Selecting it clears + /// the session's explicit model override (the gateway falls back to its + /// agent/default model). When the default model is known its name is + /// surfaced, e.g. "Default (Claude Opus 4.8)". + /// + public static string BuildDefaultEntryLabel(ChatModelChoice? defaultChoice) + { + if (defaultChoice is not null && !string.IsNullOrWhiteSpace(defaultChoice.DisplayName)) + return $"Default ({defaultChoice.DisplayName})"; + return "Default"; + } +} diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 622207671..17e09f199 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -105,6 +105,7 @@ public record ChatThread public string? Compute { get; init; } public string? ProfileName { get; init; } public string? Model { get; init; } + public string? ModelProvider { get; init; } public string? ThinkingLevel { get; init; } public long InputTokens { get; init; } public long OutputTokens { get; init; } @@ -190,7 +191,8 @@ public record ChatDataSnapshot( string? DefaultThreadId, string? ConnectionStatus, string[] AvailableModels, - ChatComposeTarget ComposeTarget); + ChatComposeTarget ComposeTarget, + IReadOnlyList? ModelChoices = null); /// /// Describes where the UI may send the next chat message. Distinct from @@ -257,6 +259,13 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default); Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default); Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default); + /// + /// Clears the session's explicit model override so it tracks the gateway's + /// agent/default model again. Providers that don't support clearing leave + /// the default no-op. Distinct from because the + /// gateway models this as an explicit null (tri-state), not an empty string. + /// + Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default); Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default); Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 884874f64..e8e44bcd2 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -2075,9 +2075,28 @@ public class ModelInfo public string? Name { get; set; } public string? Provider { get; set; } public int? ContextWindow { get; set; } + + /// True when the model's provider is configured on the gateway. public bool IsConfigured { get; set; } public bool HasConfiguredFlag { get; set; } + /// True when the gateway marks this model as the default choice. + public bool IsDefault { get; set; } + + /// + /// True when the model can be selected/used right now. Defaults to + /// true so models lists from gateways that don't report availability + /// are treated as usable. The gateway may report available:false or + /// unavailable:true to mark a model as not selectable. + /// + public bool IsAvailable { get; set; } = true; + + /// + /// True when the model's provider needs authentication/credentials before + /// it can be used (e.g. an API key has not been configured yet). + /// + public bool RequiresAuth { get; set; } + public string DisplayName => Name ?? Id; } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 1db0811d4..89ba2decf 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -4126,8 +4126,16 @@ private void ParseModelsList(JsonElement payload) { foreach (var item in modelsArray.EnumerateArray()) { + if (item.ValueKind != JsonValueKind.Object) continue; + + // Read readiness flags defensively; older gateways may omit + // them, and the UI only uses them for labels/selectability. var hasConfiguredFlag = item.TryGetProperty("configured", out var cfg) && (cfg.ValueKind == JsonValueKind.True || cfg.ValueKind == JsonValueKind.False); + bool available = true; + if (TryReadBool(item, out var av, "available")) available = av; + else if (TryReadBool(item, out var un, "unavailable")) available = !un; + var model = new ModelInfo { Id = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", @@ -4135,7 +4143,10 @@ private void ParseModelsList(JsonElement payload) Provider = item.TryGetProperty("provider", out var prov) ? prov.GetString() : null, ContextWindow = item.TryGetProperty("contextWindow", out var cw) && cw.ValueKind == JsonValueKind.Number ? cw.GetInt32() : null, IsConfigured = hasConfiguredFlag && cfg.ValueKind == JsonValueKind.True, - HasConfiguredFlag = hasConfiguredFlag + HasConfiguredFlag = hasConfiguredFlag, + IsDefault = ReadBool(item, "default", "isDefault"), + IsAvailable = available, + RequiresAuth = ReadBool(item, "requiresAuth", "authRequired", "needsAuth", "authNeeded") }; if (!string.IsNullOrEmpty(model.Id)) info.Models.Add(model); @@ -4149,6 +4160,25 @@ private void ParseModelsList(JsonElement payload) } } + // Reads the first present boolean property among . + // Returns false when none are present (or are non-boolean). + private static bool ReadBool(JsonElement obj, params string[] keys) => + TryReadBool(obj, out var value, keys) && value; + + private static bool TryReadBool(JsonElement obj, out bool value, params string[] keys) + { + foreach (var key in keys) + { + if (obj.TryGetProperty(key, out var prop)) + { + if (prop.ValueKind == JsonValueKind.True) { value = true; return true; } + if (prop.ValueKind == JsonValueKind.False) { value = false; return true; } + } + } + value = false; + return false; + } + private void ParseNodePairList(JsonElement payload) { try diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs index 3c5aaa240..0f0096beb 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs @@ -37,6 +37,13 @@ public interface IChatGatewayBridge : IDisposable Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null); Task SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null); Task PatchSessionModelAsync(string sessionKey, string model); + /// + /// Clears the session's model override (tri-state sessions.patch with + /// an explicit JSON null), reverting the session to the gateway/agent + /// default. Distinct from , which sets a + /// concrete model. + /// + Task ClearSessionModelAsync(string sessionKey); Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel); Task RequestChatHistoryAsync(string? sessionKey); Task SendChatAbortAsync(string runId, string? sessionKey = null); @@ -160,10 +167,13 @@ public Task SendChatMessageForRunAsync(string message, string? s _client.SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments); public Task PatchSessionModelAsync(string sessionKey, string model) => - _client.PatchSessionAsync(sessionKey, model: model); + _client.PatchSessionAsync(sessionKey, new SessionPatch { Model = model }); + + public Task ClearSessionModelAsync(string sessionKey) => + _client.PatchSessionAsync(sessionKey, new SessionPatch { Model = SessionPatch.Clear }); public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => - _client.PatchSessionAsync(sessionKey, thinkingLevel: thinkingLevel); + _client.PatchSessionAsync(sessionKey, new SessionPatch { ThinkingLevel = thinkingLevel }); public Task RequestChatHistoryAsync(string? sessionKey) => _client.RequestChatHistoryAsync(sessionKey); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 743e49a1b..73f31a94c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -109,6 +109,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Dictionary _sessionIds = new(); // sessionKey → immutable sessionId private readonly HashSet _historyLoaded = new(); // sessionKey private readonly HashSet _historyInFlight = new(); // sessionKey + private readonly Dictionary _pendingModelPatches = new(); // sessionKey -> in-flight model set/clear private readonly Dictionary _resetVersions = new(); // sessionKey -> reset generation private readonly Dictionary _resetCutoffUtcMs = new(); // sessionKey -> local reset time private readonly HashSet _resetAwaitingUserMessage = new(); // threads reset and waiting for first post-reset turn @@ -162,6 +163,7 @@ private sealed record LocalInlineApproval( // false on disconnect alongside `_status`. private bool _sessionsListReceived; private string[] _availableModels = Array.Empty(); + private IReadOnlyList _modelChoices = Array.Empty(); private ConnectionStatus _status; private bool _disposed; @@ -211,11 +213,17 @@ internal OpenClawChatDataProvider( // that completed before the provider was constructed will have its // models.list snapshot cached on the bridge). if (bridge.GetCurrentModelsList() is { } seedModels) - _availableModels = ExtractModelNames(seedModels); + { + _modelChoices = ChatModelChoice.FromModelsList(seedModels); + _availableModels = ModelIdsFromChoices(_modelChoices); + } // Fall back to last-known models so the composer shows a real model // name while reconnecting instead of the generic "model" placeholder. else if (_lastChatState?.AvailableModels is { Length: > 0 } cached) + { _availableModels = cached; + _modelChoices = ChoicesFromIds(cached); + } _bridge.StatusChanged += OnStatusChanged; _bridge.SessionsUpdated += OnSessionsUpdated; @@ -244,6 +252,7 @@ public Task LoadAsync(CancellationToken cancellationToken = de { _sessions = sessions; EnsureTimelinesForSessionsLocked(); + RememberLastSessionStateLocked(); return Task.FromResult(BuildSnapshotLocked()); } } @@ -336,6 +345,7 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation // 2. Send to gateway. try { + await AwaitPendingModelPatchAsync(threadId, cancellationToken); var sendResult = await _bridge.SendChatMessageForRunAsync(trimmed, threadId, sessionId, attachments); bool sendStillCurrent; lock (_gate) @@ -921,7 +931,81 @@ public Task DeleteThreadAsync(string threadId, CancellationToken cancellationTok public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - await _bridge.PatchSessionModelAsync(threadId, model); + // The gateway's sessions.patch schema treats `model` as a non-empty + // string; a blank value here is a no-op rather than a clear. Use + // ClearModelAsync to revert a session to the gateway default. + if (string.IsNullOrWhiteSpace(model)) return; + await TrackModelPatchAsync(threadId, () => _bridge.PatchSessionModelAsync(threadId, model)); + } + + public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // Tri-state clear: removes the session's model override (explicit null) + // so it tracks the gateway/agent default again. + await TrackModelPatchAsync(threadId, () => _bridge.ClearSessionModelAsync(threadId)); + } + + private async Task TrackModelPatchAsync(string threadId, Func patchOperation) + { + var startSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? previous; + Task pending; + lock (_gate) + { + _pendingModelPatches.TryGetValue(threadId, out previous); + pending = RunModelPatchAsync(previous, patchOperation, startSignal.Task); + _pendingModelPatches[threadId] = pending; + } + + startSignal.SetResult(); + try + { + await pending; + } + finally + { + lock (_gate) + { + if (_pendingModelPatches.TryGetValue(threadId, out var current) + && ReferenceEquals(current, pending)) + _pendingModelPatches.Remove(threadId); + } + } + } + + private static async Task RunModelPatchAsync(Task? previous, Func patchOperation, Task startSignal) + { + await startSignal; + if (previous is not null) + { + try { await previous; } + catch (Exception ex) + { + Logger.Debug($"ChatDataProvider: continuing model patch after previous patch failed: {ex.Message}"); + } + } + + await patchOperation(); + } + + private async Task AwaitPendingModelPatchAsync(string threadId, CancellationToken cancellationToken) + { + Task? pending; + lock (_gate) + { + _pendingModelPatches.TryGetValue(threadId, out pending); + } + + if (pending is not null) + { + try { await pending.WaitAsync(cancellationToken); } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + Logger.Debug($"ChatDataProvider: continuing send after model patch failed: {ex.Message}"); + } + } } public async Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) @@ -1336,6 +1420,7 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) SeedSessionIdsFromSessionsLocked(_sessions); _sessionsListReceived = true; EnsureTimelinesForSessionsLocked(); + RememberLastSessionStateLocked(); foreach (var s in _sessions) { if (string.IsNullOrEmpty(s.Key)) continue; @@ -1401,30 +1486,44 @@ private void OnModelsListUpdated(object? sender, ModelsListInfo info) ChatDataSnapshot snapshot; lock (_gate) { - _availableModels = ExtractModelNames(info); + _modelChoices = ChatModelChoice.FromModelsList(info); + _availableModels = ModelIdsFromChoices(_modelChoices); snapshot = BuildSnapshotLocked(); } Logger.Info($"[ChatBridge] OnModelsListUpdated: count={_availableModels.Length}"); Publish(snapshot); } - private static string[] ExtractModelNames(ModelsListInfo info) + // Wire ids (e.g. "claude-opus-4.5") in gateway order, used by the composer + // to match against SessionInfo.Model. Kept as a parallel string[] for + // back-compat with callers/persistence that only need the id list. + private static string[] ModelIdsFromChoices(IReadOnlyList choices) + { + if (choices.Count == 0) return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var ids = new List(choices.Count); + foreach (var choice in choices) + { + if (seen.Add(choice.Id)) + ids.Add(choice.Id); + } + return ids.ToArray(); + } + + // Rehydrate minimal choices from a cached id list (reconnect / pre-connect + // path) when richer gateway metadata isn't available yet. + private static IReadOnlyList ChoicesFromIds(string[] ids) { - if (info?.Models is null || info.Models.Count == 0) return Array.Empty(); - // Use model Id (wire format, e.g. "claude-opus-4.5") so the composer - // can match against SessionInfo.Model (which is also the wire Id). - // The ComboBox will show Ids directly; a future pass could introduce - // a separate display-name array if prettier labels are desired. + if (ids.Length == 0) return Array.Empty(); var seen = new HashSet(StringComparer.Ordinal); - var list = new List(info.Models.Count); - foreach (var m in info.Models) + var list = new List(ids.Length); + foreach (var id in ids) { - if (m.HasConfiguredFlag && !m.IsConfigured) continue; - var id = m.Id; if (string.IsNullOrEmpty(id)) continue; - if (seen.Add(id)) list.Add(id); + if (!seen.Add(id)) continue; + list.Add(new ChatModelChoice(id, id)); } - return list.ToArray(); + return list; } private void OnChatMessageReceived(object? sender, ChatMessageInfo message) @@ -3879,6 +3978,7 @@ private ChatDataSnapshot BuildSnapshotLocked() Id = ck, Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray", Model = _lastChatState?.Model, + ModelProvider = _lastChatState?.ModelProvider, Status = ChatThreadStatus.Running, Activity = ChatActivity.Idle, }); @@ -3896,6 +3996,7 @@ private ChatDataSnapshot BuildSnapshotLocked() Status = ChatThreadStatus.Running, Activity = ChatActivity.AwaitingPermission, Model = _lastChatState?.Model, + ModelProvider = _lastChatState?.ModelProvider, }); } @@ -3932,7 +4033,8 @@ private ChatDataSnapshot BuildSnapshotLocked() DefaultThreadId: defaultThreadId, ConnectionStatus: connectionLabel, AvailableModels: _availableModels, - ComposeTarget: composeTarget); + ComposeTarget: composeTarget, + ModelChoices: _modelChoices); } private string? ResolveDefaultThreadIdLocked() @@ -3956,6 +4058,23 @@ private ChatDataSnapshot BuildSnapshotLocked() return null; } + private void RememberLastSessionStateLocked() + { + if (_sessions.Length == 0) return; + var session = _sessions.FirstOrDefault(s => s.IsMain && !string.IsNullOrEmpty(s.Key)) + ?? _sessions.FirstOrDefault(s => !string.IsNullOrEmpty(s.Key)); + if (session is null) return; + + _lastChatState = new LastChatState + { + DefaultThreadId = session.Key, + ThreadTitle = BuildSessionTitle(session), + Model = session.Model, + ModelProvider = session.Provider, + AvailableModels = _availableModels, + }; + } + private static ChatThread ToThread(SessionInfo s) { var title = BuildSessionTitle(s); @@ -3968,6 +4087,7 @@ private static ChatThread ToThread(SessionInfo s) Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working, Workspace = s.Channel, Model = s.Model, + ModelProvider = s.Provider, ThinkingLevel = s.ThinkingLevel, InputTokens = s.InputTokens, OutputTokens = s.OutputTokens, @@ -4059,6 +4179,7 @@ internal sealed class LastChatState public string? DefaultThreadId { get; set; } public string? ThreadTitle { get; set; } public string? Model { get; set; } + public string? ModelProvider { get; set; } public string[]? AvailableModels { get; set; } } @@ -4088,12 +4209,14 @@ private void DebounceSaveLastChatState(ChatDataSnapshot snapshot) : snapshot.Threads.Length > 0 ? snapshot.Threads[0] : null; if (defaultThread is null && snapshot.AvailableModels.Length == 0) return; + var previous = _lastChatState; var state = new LastChatState { - DefaultThreadId = snapshot.DefaultThreadId, - ThreadTitle = defaultThread?.Title, - Model = defaultThread?.Model, + DefaultThreadId = snapshot.DefaultThreadId ?? previous?.DefaultThreadId, + ThreadTitle = defaultThread?.Title ?? previous?.ThreadTitle, + Model = defaultThread?.Model ?? previous?.Model, + ModelProvider = defaultThread?.ModelProvider ?? previous?.ModelProvider, AvailableModels = snapshot.AvailableModels, }; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index 9ffa6202c..6268dabab 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -260,6 +260,7 @@ Element BuildLoadingElement() Id = composeKey, Title = lastState?.ThreadTitle ?? "OpenClaw Windows Tray", Model = lastState?.Model, + ModelProvider = lastState?.ModelProvider, Status = ChatThreadStatus.Running, Activity = ChatActivity.Idle, }; @@ -548,7 +549,9 @@ Element BuildLoadingElement() AvailableChannels: channelGroups, AvailableModels: snapshot.AvailableModels, CurrentModel: composerThread.Model, + CurrentModelProvider: composerThread.ModelProvider, CurrentThinkingLevel: composerThread.ThinkingLevel, + ModelChoices: snapshot.ModelChoices, OnSend: (msg, attachments) => { SetPendingAttachments(Array.Empty()); @@ -560,7 +563,8 @@ Element BuildLoadingElement() selectedIdState.Set(id); selectedIdRef.Current = id; }, - OnModelChanged: model => RunFireAndForget(ct => _provider.SetModelAsync(composerThread.Id!, model, ct)), + OnModelChanged: model => ObserveFireAndForget(_provider.SetModelAsync(composerThread.Id!, model)), + OnModelCleared: () => ObserveFireAndForget(_provider.ClearModelAsync(composerThread.Id!)), OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(composerThread.Id!, level, ct)), OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(composerThread.Id!, allowAll, ct)), OnVoiceRequest: _onVoiceRequest, @@ -901,6 +905,19 @@ private static void RunFireAndForget(Func op) }); } + private static void ObserveFireAndForget(Task task) + { + _ = ObserveAsync(task); + + static async Task ObserveAsync(Task task) + { + try { await task; } + // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. + catch (OperationCanceledException) { /* expected */ } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[chat] op failed: {ex}"); } + } + } + private static async Task LoadAsync( IChatDataProvider provider, Action setSnapshot, diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 4d3e20fdb..6db22c76c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -46,6 +46,7 @@ public record OpenClawComposerProps( ChannelGroup[] AvailableChannels, string[] AvailableModels, string? CurrentModel, + string? CurrentModelProvider, string? CurrentThinkingLevel, Action> OnSend, Action OnStop, @@ -66,10 +67,18 @@ public record OpenClawComposerProps( Action? OnAttachmentPasted = null, bool ShowToolCalls = true, Action? OnShowToolCallsChanged = null, - bool IsCompact = false); + bool IsCompact = false, + IReadOnlyList? ModelChoices = null, + Action? OnModelCleared = null); public sealed class OpenClawComposer : Component { + // Distinct reference-equality sentinel used as the ComboBoxItem.Tag for the + // "Default" (clear model override) row, so it can never collide with a real + // model id string. Selecting it routes to OnModelCleared (tri-state clear) + // rather than OnModelChanged. + private static readonly object ClearModelTag = new(); + // Thinking levels matching the gateway's sessions.patch thinkingLevel values. // "medium" is the default when the session has no explicit thinkingLevel set. private static readonly string[] ThinkingLevelIds = { "off", "minimal", "low", "medium", "high" }; @@ -257,26 +266,102 @@ public override Element Render() border.Child = cb; }); - var models = Props.AvailableModels; - var modelIndex = models is { Length: > 0 } && Props.CurrentModel is { } cur - ? Array.IndexOf(models, cur) : -1; - if (modelIndex < 0 && models is { Length: > 0 }) modelIndex = 0; - var modelDisplay = models is { Length: > 0 } ? models : new[] { Props.CurrentModel ?? "model" }; + // ── Model picker (provider-rich) ───────────────────────────────── + IReadOnlyList modelChoices = Props.ModelChoices is { Count: > 0 } mc + ? mc + : (Props.AvailableModels is { Length: > 0 } am + ? am.Select(id => new ChatModelChoice(id, id)).ToList() + : Array.Empty()); + + var currentModelId = Props.CurrentModel; + var currentSelectionId = ChatModelChoice.ResolveSelectionId(currentModelId, Props.CurrentModelProvider, modelChoices); + var trackingDefault = ChatModelLabels.IsTrackingDefault(currentModelId); + ChatModelChoice? currentChoice = null; + ChatModelChoice? defaultChoice = null; + foreach (var c in modelChoices) + { + if (defaultChoice is null && c.IsDefault) defaultChoice = c; + if (currentChoice is null && !trackingDefault + && string.Equals(c.SelectionId, currentSelectionId, StringComparison.Ordinal)) + currentChoice = c; + } + + // Keep stale/custom current models visible even if models.list omits them. + var effectiveChoices = modelChoices; + if (!trackingDefault && currentChoice is null && !string.IsNullOrWhiteSpace(currentModelId)) + { + var synthetic = new ChatModelChoice(currentModelId!, currentModelId!, Provider: Props.CurrentModelProvider); + currentChoice = synthetic; + var augmented = new List(modelChoices.Count + 1); + augmented.AddRange(modelChoices); + augmented.Add(synthetic); + effectiveChoices = augmented; + currentSelectionId = synthetic.SelectionId; + } - var modelCombo = ComboBox(modelDisplay, Math.Max(modelIndex, 0), idx => + var modelEntries = new List<(string Label, object Tag, bool Selectable, bool IsCurrent)>(); + if (effectiveChoices.Count > 0) + modelEntries.Add((ChatModelLabels.BuildDefaultEntryLabel(defaultChoice), ClearModelTag, true, trackingDefault)); + foreach (var c in effectiveChoices) { - if (models is { Length: > 0 } && idx >= 0 && idx < models.Length) - Props.OnModelChanged(models[idx]); - }).Set(cb => + var isCur = !trackingDefault && string.Equals(c.SelectionId, currentSelectionId, StringComparison.Ordinal); + modelEntries.Add((ChatModelLabels.BuildMenuLabel(c), c.SelectionId, c.IsSelectable, isCur)); + } + if (modelEntries.Count == 0) { - cb.MinWidth = 0; - cb.Width = double.NaN; - cb.Height = 28; - cb.FontSize = 11; - cb.Padding = new Thickness(8, 0, 4, 0); - cb.CornerRadius = composerCornerRadius; - cb.HorizontalAlignment = HorizontalAlignment.Stretch; - }).VAlign(VerticalAlignment.Center); + modelEntries.Add((Props.CurrentModel ?? "model", Props.CurrentModel ?? "", false, true)); + } + + var modelSelectedIndex = modelEntries.FindIndex(e => e.IsCurrent); + + // Build directly so unavailable rows can be displayed but not selected. + var modelCombo = Border() + .Set(border => + { + var cb = new ComboBox + { + MinWidth = 0, + Width = double.NaN, + Height = 28, + FontSize = 11, + Padding = new Thickness(8, 0, 4, 0), + CornerRadius = composerCornerRadius, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + }; + + ComboBoxItem? selectedItem = null; + for (int i = 0; i < modelEntries.Count; i++) + { + var entry = modelEntries[i]; + var item = new ComboBoxItem + { + Content = entry.Label, + Tag = entry.Tag, + IsEnabled = entry.Selectable, + Padding = new Thickness(8, 4, 4, 4), + }; + cb.Items.Add(item); + if (i == modelSelectedIndex) selectedItem = item; + } + + if (selectedItem != null) + cb.SelectedItem = selectedItem; + + var onModelChanged = Props.OnModelChanged; + var onModelCleared = Props.OnModelCleared; + cb.SelectionChanged += (_, _) => + { + if (cb.SelectedItem is not ComboBoxItem { IsEnabled: true } sel) return; + if (ReferenceEquals(sel.Tag, ClearModelTag)) + onModelCleared?.Invoke(); + else if (sel.Tag is string id && !string.IsNullOrEmpty(id)) + onModelChanged(id); + }; + + border.Child = cb; + }) + .VAlign(VerticalAlignment.Center); var thinkingLevel = Props.CurrentThinkingLevel ?? "medium"; var thinkingIndex = Array.IndexOf(ThinkingLevelIds, thinkingLevel); diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 24031766e..581fec3d6 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -266,7 +266,7 @@ public void ParseSessionsPayload(string payloadJson) public ModelsListInfo ParseModelsListPayload(string payloadJson) { ModelsListInfo? parsed = null; - EventHandler handler = (_, models) => parsed = models; + EventHandler handler = (_, info) => parsed = info; _client.ModelsListUpdated += handler; try @@ -1685,6 +1685,97 @@ public void ParseNodeListPayload_EmptyArray_ReturnsEmpty() Assert.Empty(nodes); } + [Fact] + public void ParseModelsList_PopulatesProviderRichMetadata() + { + var helper = new GatewayClientTestHelper(); + var info = helper.ParseModelsListPayload(""" + { + "models": [ + { + "id": "claude-opus-4.8", + "name": "Claude Opus 4.8", + "provider": "Anthropic", + "contextWindow": 200000, + "configured": true, + "default": true + }, + { + "id": "gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "provider": "Google", + "contextWindow": 1000000, + "requiresAuth": true + }, + { + "id": "local-llama", + "provider": "Ollama", + "unavailable": true + } + ] + } + """); + + Assert.Equal(3, info.Models.Count); + + var opus = info.Models[0]; + Assert.Equal("claude-opus-4.8", opus.Id); + Assert.Equal("Anthropic", opus.Provider); + Assert.Equal(200000, opus.ContextWindow); + Assert.True(opus.IsConfigured); + Assert.True(opus.IsDefault); + Assert.True(opus.IsAvailable); + Assert.False(opus.RequiresAuth); + + var gemini = info.Models[1]; + Assert.True(gemini.RequiresAuth); + Assert.False(gemini.IsDefault); + Assert.True(gemini.IsAvailable); // no availability signal → usable + + var llama = info.Models[2]; + Assert.False(llama.IsAvailable); // unavailable:true inverts to false + Assert.Equal("local-llama", llama.DisplayName); // name omitted → id + } + + [Fact] + public void ParseModelsList_DefaultsAvailableTrue_WhenNoReadinessSignals() + { + var helper = new GatewayClientTestHelper(); + var info = helper.ParseModelsListPayload(""" + { "models": [ { "id": "gpt-5.5", "name": "GPT-5.5" } ] } + """); + + var m = Assert.Single(info.Models); + Assert.True(m.IsAvailable); + Assert.False(m.RequiresAuth); + Assert.False(m.IsDefault); + Assert.False(m.IsConfigured); + } + + [Fact] + public void ParseModelsList_AvailableFalse_MarksUnavailable() + { + var helper = new GatewayClientTestHelper(); + var info = helper.ParseModelsListPayload(""" + { "models": [ { "id": "x", "available": false } ] } + """); + + Assert.False(Assert.Single(info.Models).IsAvailable); + } + + [Fact] + public void ParseModelsList_AcceptsIsDefaultAndAuthNeededAliases() + { + var helper = new GatewayClientTestHelper(); + var info = helper.ParseModelsListPayload(""" + { "models": [ { "id": "x", "isDefault": true, "authNeeded": true } ] } + """); + + var m = Assert.Single(info.Models); + Assert.True(m.IsDefault); + Assert.True(m.RequiresAuth); + } + [Fact] public void ParseNodeListPayload_SameOnlineStatus_SortsByLastSeenDescending() { diff --git a/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs new file mode 100644 index 000000000..1b31d2ae6 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs @@ -0,0 +1,285 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; + +namespace OpenClaw.Tray.Tests; + +public class ChatModelChoiceTests +{ + // ── FromModelsList mapping ─────────────────────────────────────────── + + [Fact] + public void FromModelsList_MapsAllFields() + { + var info = new ModelsListInfo + { + Models = + { + new ModelInfo + { + Id = "claude-opus-4.8", + Name = "Claude Opus 4.8", + Provider = "Anthropic", + ContextWindow = 200000, + IsConfigured = true, + IsDefault = true, + IsAvailable = true, + RequiresAuth = false, + }, + } + }; + + var choices = ChatModelChoice.FromModelsList(info); + + var c = Assert.Single(choices); + Assert.Equal("claude-opus-4.8", c.Id); + Assert.Equal("Anthropic/claude-opus-4.8", c.SelectionId); + Assert.Equal("Claude Opus 4.8", c.DisplayName); + Assert.Equal("Anthropic", c.Provider); + Assert.Equal(200000, c.ContextWindow); + Assert.True(c.IsConfigured); + Assert.True(c.IsDefault); + Assert.True(c.IsAvailable); + Assert.True(c.IsSelectable); + } + + [Fact] + public void FromModelsList_DedupesBySelectionId_FirstWins_SkipsEmptyIds() + { + var info = new ModelsListInfo + { + Models = + { + new ModelInfo { Id = "gpt-5.4", Name = "GPT-5.4", Provider = "openai" }, + new ModelInfo { Id = "gpt-5.4", Name = "GPT-5.4 via OpenRouter", Provider = "openrouter" }, + new ModelInfo { Id = "gpt-5.4", Name = "dupe", Provider = "openai" }, + new ModelInfo { Id = "", Name = "blank" }, + } + }; + + var choices = ChatModelChoice.FromModelsList(info); + + Assert.Equal(2, choices.Count); + Assert.Equal("GPT-5.4", choices[0].DisplayName); + Assert.Equal("openai/gpt-5.4", choices[0].SelectionId); + Assert.Equal("GPT-5.4 via OpenRouter", choices[1].DisplayName); + Assert.Equal("openrouter/gpt-5.4", choices[1].SelectionId); + } + + [Fact] + public void FromModelsList_NullOrEmpty_ReturnsEmpty() + { + Assert.Empty(ChatModelChoice.FromModelsList(null)); + Assert.Empty(ChatModelChoice.FromModelsList(new ModelsListInfo())); + } + + [Fact] + public void FromModelsList_FallsBackToIdWhenNameMissing() + { + var info = new ModelsListInfo { Models = { new ModelInfo { Id = "ollama-x" } } }; + Assert.Equal("ollama-x", ChatModelChoice.FromModelsList(info)[0].DisplayName); + } + + [Fact] + public void FromModelsList_HidesExplicitlyUnconfiguredModels() + { + var info = new ModelsListInfo + { + Models = + { + // Provider explicitly reported as not configured with no auth path → hidden. + new ModelInfo { Id = "unconfigured", HasConfiguredFlag = true, IsConfigured = false }, + // Auth-needed rows stay visible so users can choose the provider-auth path. + new ModelInfo { Id = "needs-key", HasConfiguredFlag = true, IsConfigured = false, RequiresAuth = true }, + // Configured → kept. + new ModelInfo { Id = "ready", HasConfiguredFlag = true, IsConfigured = true }, + // Flag omitted entirely → kept (we don't know, so don't hide). + new ModelInfo { Id = "unknown" }, + } + }; + + var choices = ChatModelChoice.FromModelsList(info); + Assert.Equal(new[] { "needs-key", "ready", "unknown" }, choices.Select(c => c.Id).ToArray()); + Assert.True(choices[0].RequiresAuth); + Assert.True(choices[0].IsSelectable); + } + + // ── Selectability ──────────────────────────────────────────────────── + + [Fact] + public void IsSelectable_FalseOnlyWhenUnavailable() + { + Assert.True(new ChatModelChoice("x", "X").IsSelectable); + // Auth-needed stays selectable (routes to provider auth). + Assert.True(new ChatModelChoice("x", "X", RequiresAuth: true).IsSelectable); + Assert.False(new ChatModelChoice("x", "X", IsAvailable: false).IsSelectable); + } + + [Theory] + [InlineData("gpt-5.4", "openai", "openai/gpt-5.4")] + [InlineData("openai/gpt-5.4", "openai", "openai/gpt-5.4")] + [InlineData("openai/gpt-5.4", "vercel-ai-gateway", "vercel-ai-gateway/openai/gpt-5.4")] + [InlineData("custom-model", null, "custom-model")] + public void SelectionId_ProviderQualifiesRawModelIds(string modelId, string? provider, string expected) + { + var c = new ChatModelChoice(modelId, modelId, Provider: provider); + Assert.Equal(expected, c.SelectionId); + } + + [Fact] + public void ResolveSelectionId_UsesProviderToDisambiguateDuplicateRawModelIds() + { + var choices = new[] + { + new ChatModelChoice("gpt-5.4", "GPT-5.4", Provider: "openai"), + new ChatModelChoice("gpt-5.4", "GPT-5.4", Provider: "openrouter"), + }; + + Assert.Equal( + "openrouter/gpt-5.4", + ChatModelChoice.ResolveSelectionId("gpt-5.4", "openrouter", choices)); + Assert.Equal("gpt-5.4", ChatModelChoice.ResolveSelectionId("gpt-5.4", null, choices)); + } + + [Fact] + public void ResolveSelectionId_MatchesProviderCaseInsensitively() + { + var choices = new[] + { + new ChatModelChoice("gpt-5.4", "GPT-5.4", Provider: "Anthropic"), + }; + + Assert.Equal( + "Anthropic/gpt-5.4", + ChatModelChoice.ResolveSelectionId("gpt-5.4", "anthropic", choices)); + } + + [Fact] + public void ResolveSelectionId_UsesBareCachedChoiceWhenProviderRichChoiceIsUnavailable() + { + var choices = new[] + { + new ChatModelChoice("gpt-5.4", "GPT-5.4"), + }; + + Assert.Equal("gpt-5.4", ChatModelChoice.ResolveSelectionId("gpt-5.4", "openrouter", choices)); + } + + // ── Tracking-default predicate ─────────────────────────────────────── + + [Theory] + [InlineData(null, true)] + [InlineData("", true)] + [InlineData("gpt-5.5", false)] + public void IsTrackingDefault_DetectsEmptyOrNull(string? id, bool expected) => + Assert.Equal(expected, ChatModelLabels.IsTrackingDefault(id)); + + // ── Context-window formatting ──────────────────────────────────────── + + [Theory] + [InlineData(272000, "272K")] + [InlineData(200000, "200K")] + [InlineData(128000, "128K")] + [InlineData(1000000, "1M")] + [InlineData(2000000, "2M")] + [InlineData(1500000, "1.5M")] + [InlineData(8000, "8K")] + [InlineData(500, "500")] + [InlineData(0, "")] + public void FormatContextWindow_FormatsCompactly(int contextWindow, string expected) => + Assert.Equal(expected, ChatModelLabels.FormatContextWindow(contextWindow)); + + // ── Meta segment ───────────────────────────────────────────────────── + + [Fact] + public void BuildMetaSegment_CombinesProviderAndContext() + { + var c = new ChatModelChoice("x", "X", Provider: "OpenAI", ContextWindow: 272000); + Assert.Equal("OpenAI · 272K", ChatModelLabels.BuildMetaSegment(c)); + } + + [Fact] + public void BuildMetaSegment_ProviderOnly() + { + var c = new ChatModelChoice("x", "X", Provider: "OpenAI"); + Assert.Equal("OpenAI", ChatModelLabels.BuildMetaSegment(c)); + } + + [Fact] + public void BuildMetaSegment_ContextOnly() + { + var c = new ChatModelChoice("x", "X", ContextWindow: 200000); + Assert.Equal("200K", ChatModelLabels.BuildMetaSegment(c)); + } + + [Fact] + public void BuildMetaSegment_NeitherKnown_ReturnsEmpty() => + Assert.Equal("", ChatModelLabels.BuildMetaSegment(new ChatModelChoice("x", "X"))); + + // ── State markers ──────────────────────────────────────────────────── + + [Fact] + public void BuildStateMarker_Unavailable_TakesPrecedence() + { + var c = new ChatModelChoice("x", "X", IsAvailable: false, RequiresAuth: true, IsDefault: true); + Assert.Equal("unavailable", ChatModelLabels.BuildStateMarker(c)); + } + + [Fact] + public void BuildStateMarker_AuthNeeded_BeforeDefault() + { + var c = new ChatModelChoice("x", "X", RequiresAuth: true, IsDefault: true); + Assert.Equal("auth needed", ChatModelLabels.BuildStateMarker(c)); + } + + [Fact] + public void BuildStateMarker_Default() + { + var c = new ChatModelChoice("x", "X", IsDefault: true); + Assert.Equal("default", ChatModelLabels.BuildStateMarker(c)); + } + + [Fact] + public void BuildStateMarker_MissingConfiguredFlag_IsNotAuthNeeded() + { + // Gateway's "configured" view often omits the flag; absence must not be + // mistaken for an auth requirement. + var c = new ChatModelChoice("x", "X", IsConfigured: false); + Assert.Equal("", ChatModelLabels.BuildStateMarker(c)); + } + + // ── Full menu label ────────────────────────────────────────────────── + + [Fact] + public void BuildMenuLabel_Full() + { + var c = new ChatModelChoice("claude-opus-4.8", "Claude Opus 4.8", Provider: "Anthropic", ContextWindow: 200000, IsDefault: true); + Assert.Equal("Claude Opus 4.8 · Anthropic · 200K · default", ChatModelLabels.BuildMenuLabel(c)); + } + + [Fact] + public void BuildMenuLabel_AuthNeeded() + { + var c = new ChatModelChoice("gemini-3.1-pro", "Gemini 3.1 Pro", Provider: "Google", ContextWindow: 1000000, RequiresAuth: true); + Assert.Equal("Gemini 3.1 Pro · Google · 1M · auth needed", ChatModelLabels.BuildMenuLabel(c)); + } + + [Fact] + public void BuildMenuLabel_BareModel() + { + var c = new ChatModelChoice("custom-id", "custom-id"); + Assert.Equal("custom-id", ChatModelLabels.BuildMenuLabel(c)); + } + + // ── Default (clear-to-default) entry label ─────────────────────────── + + [Fact] + public void BuildDefaultEntryLabel_NamesDefaultModelWhenKnown() + { + var def = new ChatModelChoice("claude-opus-4.8", "Claude Opus 4.8", IsDefault: true); + Assert.Equal("Default (Claude Opus 4.8)", ChatModelLabels.BuildDefaultEntryLabel(def)); + } + + [Fact] + public void BuildDefaultEntryLabel_PlainWhenDefaultUnknown() => + Assert.Equal("Default", ChatModelLabels.BuildDefaultEntryLabel(null)); +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index a7e7a2f9d..afa98cc15 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 0dd0f0e56..cdd3921d9 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -20,6 +20,8 @@ private sealed class FakeBridge : IChatGatewayBridge public Queue SendResults { get; } = new(); public List AbortedRunIds { get; } = new(); public Func? SendBehavior { get; set; } + public Func? PatchSessionModelBehavior { get; set; } + public Func? ClearSessionModelBehavior { get; set; } public Func>? HistoryBehavior { get; set; } public Func? AbortBehavior { get; set; } public SessionInfo[] Sessions { get; set; } = Array.Empty(); @@ -44,7 +46,20 @@ public async Task SendChatMessageForRunAsync(string message, str return SendResults.Count > 0 ? SendResults.Dequeue() : new ChatSendResult(); } - public Task PatchSessionModelAsync(string sessionKey, string model) => Task.CompletedTask; + public Task PatchSessionModelAsync(string sessionKey, string model) + { + PatchedModelKeys.Add(sessionKey); + PatchedModels.Add(model); + return PatchSessionModelBehavior?.Invoke(sessionKey, model) ?? Task.CompletedTask; + } + public List PatchedModelKeys { get; } = new(); + public List PatchedModels { get; } = new(); + public Task ClearSessionModelAsync(string sessionKey) + { + ClearedModelKeys.Add(sessionKey); + return ClearSessionModelBehavior?.Invoke(sessionKey) ?? Task.CompletedTask; + } + public List ClearedModelKeys { get; } = new(); public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => Task.CompletedTask; public Task RequestChatHistoryAsync(string? sessionKey) @@ -132,6 +147,20 @@ public async Task LoadAsync_ReturnsSeededSessionsAsThreads() Assert.True(snapshot.Timelines.ContainsKey("main")); } + [Fact] + public async Task LoadAsync_CarriesSessionModelProviderToThreads() + { + var session = MainSession(); + session.Model = "gpt-5.4"; + session.Provider = "openrouter"; + var (_, provider, _, _) = CreateProvider(new[] { session }); + + var snapshot = await provider.LoadAsync(); + + Assert.Equal("gpt-5.4", snapshot.Threads[0].Model); + Assert.Equal("openrouter", snapshot.Threads[0].ModelProvider); + } + [Fact] public async Task SendMessageAsync_AddsLocalUserEntryBeforeAwaitingGateway() { @@ -1943,13 +1972,15 @@ public async Task ModelsListUpdated_FiltersExplicitlyUnconfiguredModels() { new() { Id = "gpt-5.4", IsConfigured = true, HasConfiguredFlag = true }, new() { Id = "gpt-5.5", IsConfigured = false, HasConfiguredFlag = true }, + new() { Id = "needs-auth", IsConfigured = false, HasConfiguredFlag = true, RequiresAuth = true }, new() { Id = "legacy-gateway-model" } } }); Assert.Equal( - new[] { "gpt-5.4", "legacy-gateway-model" }, + new[] { "gpt-5.4", "needs-auth", "legacy-gateway-model" }, snapshots[^1].AvailableModels); + Assert.True(snapshots[^1].ModelChoices!.Single(c => c.Id == "needs-auth").RequiresAuth); } [Fact] @@ -1991,7 +2022,220 @@ public async Task LoadAsync_SeedsModelsFromBridgeSnapshot() Assert.Equal(new[] { "x" }, snap.AvailableModels); } - // ── Iteration 4: per-entry metadata (timestamp + model) ── + [Fact] + public async Task ModelsListUpdated_PopulatesProviderRichChoices() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseModels(new ModelsListInfo + { + Models = new List + { + new() { Id = "claude-opus-4.8", Name = "Claude Opus 4.8", Provider = "Anthropic", ContextWindow = 200000, IsDefault = true }, + new() { Id = "gemini-3.1-pro", Name = "Gemini 3.1 Pro", Provider = "Google", ContextWindow = 1000000, RequiresAuth = true }, + new() { Id = "local-llama", Provider = "Ollama", IsAvailable = false }, + } + }); + + var choices = snapshots[^1].ModelChoices; + Assert.NotNull(choices); + Assert.Equal(3, choices!.Count); + + Assert.Equal("claude-opus-4.8", choices[0].Id); + Assert.Equal("Anthropic/claude-opus-4.8", choices[0].SelectionId); + Assert.Equal("Claude Opus 4.8", choices[0].DisplayName); + Assert.Equal("Anthropic", choices[0].Provider); + Assert.Equal(200000, choices[0].ContextWindow); + Assert.True(choices[0].IsDefault); + + Assert.True(choices[1].RequiresAuth); + Assert.False(choices[2].IsAvailable); + Assert.False(choices[2].IsSelectable); + + // AvailableModels stays a parallel id list for back-compat. + Assert.Equal(new[] { "claude-opus-4.8", "gemini-3.1-pro", "local-llama" }, snapshots[^1].AvailableModels); + } + + [Fact] + public async Task ModelsListUpdated_DedupesChoicesById() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseModels(new ModelsListInfo + { + Models = new List + { + new() { Id = "gpt-5.4", Name = "GPT-5.4" }, + new() { Id = "gpt-5.4", Name = "GPT-5.4 (dupe)" }, + new() { Id = "", Name = "no id" }, + } + }); + + var choices = snapshots[^1].ModelChoices!; + Assert.Single(choices); + Assert.Equal("gpt-5.4", choices[0].Id); + Assert.Equal("GPT-5.4", choices[0].DisplayName); // first wins + } + + [Fact] + public async Task ModelsListUpdated_KeepsDuplicateRawModelIdsFromDifferentProviders() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseModels(new ModelsListInfo + { + Models = new List + { + new() { Id = "gpt-5.4", Name = "GPT-5.4", Provider = "openai" }, + new() { Id = "gpt-5.4", Name = "GPT-5.4 via OpenRouter", Provider = "openrouter" }, + } + }); + + var choices = snapshots[^1].ModelChoices!; + Assert.Equal(2, choices.Count); + Assert.Equal("openai/gpt-5.4", choices[0].SelectionId); + Assert.Equal("openrouter/gpt-5.4", choices[1].SelectionId); + Assert.Equal(new[] { "gpt-5.4" }, snapshots[^1].AvailableModels); + } + + [Fact] + public async Task SetModelAsync_ForwardsModelToBridge() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + await provider.SetModelAsync("main", "claude-opus-4.8"); + + Assert.Equal(new[] { "main" }, bridge.PatchedModelKeys); + Assert.Equal(new[] { "claude-opus-4.8" }, bridge.PatchedModels); + } + + [Fact] + public async Task SetModelAsync_EmptyModel_IsNoOp_NotSent() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + // The gateway's sessions.patch schema rejects an empty model (NonEmpty + // string); a blank Set is a no-op. Clearing goes through ClearModelAsync. + await provider.SetModelAsync("main", ""); + await provider.SetModelAsync("main", " "); + + Assert.Empty(bridge.PatchedModels); + Assert.Empty(bridge.ClearedModelKeys); + } + + [Fact] + public async Task ClearModelAsync_ClearsOverrideViaBridge() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + // The picker's "Default" entry clears the session's model override + // (tri-state sessions.patch null) — distinct from a Set. + await provider.ClearModelAsync("main"); + + Assert.Equal(new[] { "main" }, bridge.ClearedModelKeys); + Assert.Empty(bridge.PatchedModels); + } + + [Fact] + public async Task SendMessageAsync_WaitsForInFlightModelPatchBeforeGatewaySend() + { + var patchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.PatchSessionModelBehavior = (_, _) => + { + patchStarted.TrySetResult(); + return releasePatch.Task; + }; + await provider.LoadAsync(); + snapshots.Clear(); + + var modelTask = provider.SetModelAsync("main", "openai/gpt-5.4"); + var sendTask = provider.SendMessageAsync("main", "Hello"); + await Task.Delay(50); + + Assert.Single(snapshots); + Assert.Empty(bridge.SentMessages); + + await patchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + releasePatch.SetResult(); + await Task.WhenAll(modelTask, sendTask); + + Assert.Equal(new[] { "openai/gpt-5.4" }, bridge.PatchedModels); + Assert.Equal(new[] { "Hello" }, bridge.SentMessages); + } + + [Fact] + public async Task SendMessageAsync_ContinuesWhenInFlightModelPatchFails() + { + var patchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.PatchSessionModelBehavior = async (_, _) => + { + patchStarted.SetResult(); + await releasePatch.Task; + throw new InvalidOperationException("patch failed"); + }; + await provider.LoadAsync(); + snapshots.Clear(); + + var modelTask = provider.SetModelAsync("main", "openai/gpt-5.4"); + await patchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + var sendTask = provider.SendMessageAsync("main", "Hello"); + await Task.Delay(50); + + Assert.Empty(bridge.SentMessages); + releasePatch.SetResult(); + + await Assert.ThrowsAsync(() => modelTask); + await sendTask; + + Assert.Equal(new[] { "openai/gpt-5.4" }, bridge.PatchedModels); + Assert.Equal(new[] { "Hello" }, bridge.SentMessages); + Assert.DoesNotContain( + snapshots.SelectMany(s => s.Timelines["main"].Entries), + e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("patch failed")); + } + + [Fact] + public async Task ModelPatches_AreSerializedSoLatestSelectionCannotBeOvertaken() + { + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.PatchSessionModelBehavior = (_, model) => + { + if (model == "openai/gpt-5.4") + return releaseFirst.Task; + if (model == "openai/gpt-5.4-pro") + secondStarted.SetResult(); + return Task.CompletedTask; + }; + await provider.LoadAsync(); + + var firstTask = provider.SetModelAsync("main", "openai/gpt-5.4"); + var secondTask = provider.SetModelAsync("main", "openai/gpt-5.4-pro"); + await Task.Delay(50); + + Assert.False(secondStarted.Task.IsCompleted); + + releaseFirst.SetResult(); + await Task.WhenAll(firstTask, secondTask); + + Assert.True(secondStarted.Task.IsCompleted); + Assert.Equal(new[] { "openai/gpt-5.4", "openai/gpt-5.4-pro" }, bridge.PatchedModels); + } + [Fact] public async Task LoadHistoryAsync_CapturesPerEntryTimestamps() @@ -3866,6 +4110,37 @@ public async Task LocalExecApproval_InlineDecisionCompletesPromptAndAddsHistoryR e.Text.Contains("del \"E:\\Temp\\sample.txt\"", StringComparison.Ordinal)); } + [Fact] + public async Task LocalExecApproval_SyntheticThreadUsesCachedModelProvider() + { + var session = MainSession(); + session.Model = "gpt-5.4"; + session.Provider = "openrouter"; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { session }); + await provider.LoadAsync(); + Assert.Equal("gpt-5.4", provider.CachedLastChatState?.Model); + Assert.Equal("openrouter", provider.CachedLastChatState?.ModelProvider); + bridge.RaiseSessions(Array.Empty()); + Assert.Equal("gpt-5.4", provider.CachedLastChatState?.Model); + Assert.Equal("openrouter", provider.CachedLastChatState?.ModelProvider); + snapshots.Clear(); + + var promptTask = provider.RequestLocalExecApprovalAsync(new ExecApprovalPromptRequest + { + Command = "tasklist", + Shell = "cmd", + SessionKey = "main", + CorrelationId = "provider-context" + }); + + var synthetic = Assert.Single(snapshots[^1].Threads, t => t.Id == "main"); + Assert.Equal("gpt-5.4", synthetic.Model); + Assert.Equal("openrouter", synthetic.ModelProvider); + + await provider.RespondToPermissionAsync("main", "local-provider-context", ChatPermissionActionKeys.Deny); + await promptTask; + } + [Fact] public async Task LocalExecApproval_TimeoutExpiresEntryAndCompletesAsTimedOutDeny() { diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs index 4cf8cd3e0..06f551f8d 100644 --- a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs +++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs @@ -366,6 +366,7 @@ public void StartProactiveBootstrap() { } public Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null) => Task.CompletedTask; public Task SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null) => Task.FromResult(new ChatSendResult()); public Task PatchSessionModelAsync(string sessionKey, string model) => Task.CompletedTask; + public Task ClearSessionModelAsync(string sessionKey) => Task.CompletedTask; public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => Task.CompletedTask; public Task RequestChatHistoryAsync(string? sessionKey) => Task.FromResult(History); public Task SendChatAbortAsync(string runId, string? sessionKey = null) => Task.CompletedTask;