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;