diff --git a/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml index 714a2391e5..6587b9d28a 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml @@ -74,6 +74,15 @@ + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs index ae51a0e079..1c74e4639a 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/AiSetupPage.xaml.cs @@ -7,6 +7,7 @@ using OpenClaw.SetupEngine.UI.Controls; using OpenClaw.Shared; using OpenClaw.Connection; +using OpenClaw.Connection.LocalAi; using OpenClawTray.Helpers; namespace OpenClaw.SetupEngine.UI.Pages; @@ -72,6 +73,8 @@ private sealed record ProviderBackdrop(Control? FocusTarget, double VerticalOffs private LocalAiOnboardingObservation? _localObservation; private bool _localActionBusy; private LocalAiOnboardingUse? _localUse; + private LocalAiFailureDetail? _localAiFailure; + private int _localAiFailureVersion; private string? _localExpectedModel => _localUse?.Expected?.ModelRef; private string? ExpectedGatewayId => _localUse?.Expected?.GatewayId ?? _args?.ExpectedGatewayId; private readonly ProviderArtworkSession _artworkSession = new(); @@ -81,6 +84,7 @@ public AiSetupPage() { InitializeComponent(); TitleText.Text = S("Title.Text"); + OpenLocalAiLogsButton.Content = S("OpenLocalAiLogs"); CandidatesHeading.Text = SetupLocalization.GetString("Onboarding_V4_DetectedAi.Text"); RecommendedHeading.Text = S("Recommended"); RecommendedDetail.Text = S("RecommendedDetail"); @@ -345,11 +349,8 @@ await GatewayAiSetupTransport.BorrowNativeAsync(_args.DataDir, manager, await _localObservation!.RefreshAsync(CreateLocalProgress(ct)); TitleText.Text = S("Title.Text"); await DetectAsync(ct); - if (ex is LocalAiStartFailedException) - { - ErrorBar.Message = S("LocalStartFailed") + " " + ex.Message; - ErrorBar.IsOpen = true; - } + if (ex is LocalAiStartFailedException startFailed) + ShowLocalAiStartFailure(startFailed); else ShowError("LocalChanged"); return; @@ -409,6 +410,7 @@ void ReportIssue(GatewayLogTailIssue issue) => DispatcherQueue.TryEnqueue(() => _nativeConsoleIssue = issue; var message = SetupLocalization.GetString(issue == GatewayLogTailIssue.Skipped ? "Onboarding_Wizard_GatewayConsoleGap" : "Onboarding_Wizard_GatewayConsoleUnavailable"); + ClearLocalAiFailure(); if (BackdropLocked) _providerError = message; else { @@ -644,10 +646,7 @@ await RunAsync(async ct => if (_closed) return; await _localObservation.RefreshAsync(); if (!_closed) - { - ErrorBar.Message = S("LocalStartFailed") + " " + ex.Message; - ErrorBar.IsOpen = true; - } + ShowLocalAiStartFailure(ex); return; } finally { ++_progressScope; } @@ -885,6 +884,7 @@ private async Task RunCoreAsync(Func action, bool submi _activityStatus = SetupInstallationStatus.Running; // Uncertain is also used for recovery. Retain a prompt only for its own answer request. _submittingAnswer = submittingAnswer; + ClearLocalAiFailure(); if (BackdropLocked) _providerError = null; else ErrorBar.IsOpen = false; Render(); @@ -1027,6 +1027,7 @@ private void Render() LocalAiCard.IsClickEnabled = false; LocalAiCard.IsEnabled = false; } + LocalAiFailurePanel.Visibility = Visible(!freezeBackdrop && ErrorBar.IsOpen && _localAiFailure is not null); var backgroundEnabled = !_busy && !_localActionBusy && !BackdropLocked; CandidateChoices.IsEnabled = PrepareChoices.IsEnabled = FeaturedSignInChoices.IsEnabled = MoreSignInChoices.IsEnabled = ApiProviderPicker.IsEnabled = ApiKeyInput.IsEnabled = @@ -1217,6 +1218,7 @@ private bool CanConnectManual() => private static Visibility Visible(bool value) => value ? Visibility.Visible : Visibility.Collapsed; private void ShowError(string key, string? detail = null) { + ClearLocalAiFailure(); var message = S(key); detail ??= key is "VerificationFailed" or "Rejected" ? Client?.VerificationFailure ?? Client?.Wizard?.Error : null; @@ -1232,8 +1234,55 @@ private void ShowError(string key, string? detail = null) ErrorBar.Message = message; ErrorBar.IsOpen = true; } + if (key is "VerificationFailed" or "Rejected" && _localUse?.Expected is not null) + CaptureLocalAiFailure(); Render(); } + + private void ClearLocalAiFailure() + { + _localAiFailure = null; + ++_localAiFailureVersion; + } + + /// + /// llama-server reports model-load causes only in its own logs; the Gateway's verification + /// error does not carry them. Reads the bounded log tail off the request path and shows it + /// with a link to the folder, mirroring CompletePage.ShowServerDiagnostics. + /// + private void CaptureLocalAiFailure() + { + if (_args is null) return; + var version = ++_localAiFailureVersion; + var paths = new LocalAiPaths(_args.LocalDataDir); + AsyncEventHandlerGuard.Run(async () => + { + var diagnostics = await LocalAiLogTail.ReadDiagnosticLinesAsync(paths, CancellationToken.None); + if (_closed || version != _localAiFailureVersion) return; + var directory = LogFileLauncher.ResolveRealPath(paths.LogsDirectory); + var hasDirectory = Directory.Exists(directory); + if (diagnostics.Count == 0 && !hasDirectory) return; + _localAiFailure = new LocalAiFailureDetail(diagnostics, hasDirectory ? directory : ""); + LocalAiDiagnosticsText.Text = string.Join(Environment.NewLine, + diagnostics.Select(line => $"llama-server: {line}")); + LocalAiDiagnosticsText.Visibility = Visible(diagnostics.Count > 0); + OpenLocalAiLogsButton.Visibility = Visible(hasDirectory); + ToolTipService.SetToolTip(OpenLocalAiLogsButton, hasDirectory ? directory : null); + Render(); + }, onError: ex => Trace.TraceWarning("Local AI diagnostics could not be read ({0}).", ex.GetType().Name)); + } + + private void ShowLocalAiStartFailure(LocalAiStartFailedException error) + { + ClearLocalAiFailure(); + ErrorBar.Message = S("LocalStartFailed") + " " + error.Message; + ErrorBar.IsOpen = true; + CaptureLocalAiFailure(); + } + + private void OpenLocalAiLogs_Click(object sender, RoutedEventArgs e) => + LogFileLauncher.RevealInExplorer(_localAiFailure?.LogDirectory); + private void ReportFailure(Exception error) { Trace.TraceWarning("AI setup request failed ({0}).", error.GetType().Name); diff --git a/src/OpenClaw.SetupEngine/LocalAiLogTail.cs b/src/OpenClaw.SetupEngine/LocalAiLogTail.cs index 77d3095b46..c028aa2395 100644 --- a/src/OpenClaw.SetupEngine/LocalAiLogTail.cs +++ b/src/OpenClaw.SetupEngine/LocalAiLogTail.cs @@ -72,17 +72,22 @@ internal static async Task> ReadDiagnosticLinesAsync( } /// - /// Keeps the last distinct lines that look like a failure, in - /// file order. llama.cpp reports the proximate cause (a CUDA kernel fault, a nonzero instance - /// exit status) close to the end of the log, so the tail is the interesting part. + /// Keeps at most distinct failure lines, in log order. Cause + /// lines (a CUDA fault, an allocation failure, a model-load error) take precedence over + /// consequence lines (a "failed to load" echo, a nonzero instance exit status), which fill + /// the remaining slots newest first. The input is the stdout tail followed by the stderr tail, + /// so the router's stderr consequences always sort last and would otherwise push a model + /// instance's cause out of a plain last-N window. Lines that differ only by the router's + /// "[pid]" instance prefix are one line, kept at its newest occurrence, so repeated + /// attempts of the same failing load do not fill every slot. /// internal static IReadOnlyList ExtractDiagnosticLines(string log, int maximumLines = 4) { if (string.IsNullOrWhiteSpace(log)) return []; - var matches = new List(); - var seen = new HashSet(StringComparer.Ordinal); + var newest = new Dictionary(StringComparer.Ordinal); + int order = 0; foreach (string rawLine in log.Split('\n')) { string line = rawLine.Trim(); @@ -90,16 +95,20 @@ internal static IReadOnlyList ExtractDiagnosticLines(string log, int max RequestOrResponsePattern().IsMatch(line) || !DiagnosticPattern().IsMatch(line)) continue; + bool isCause = CausePattern().IsMatch(line); line = NormalizeSingleLine(TokenSanitizer.SanitizeLogMessage(line)); if (line.Length > MaximumDiagnosticLineLength) line = line[..MaximumDiagnosticLineLength]; - if (seen.Add(line)) - matches.Add(line); + newest[InstancePrefixPattern().Replace(line, "")] = (order++, line, isCause); } - return matches.Count <= maximumLines - ? matches - : matches[^maximumLines..]; + var byRecency = newest.Values.OrderByDescending(match => match.Order).ToList(); + var causes = byRecency.Where(match => match.IsCause).Take(maximumLines).ToList(); + return causes + .Concat(byRecency.Where(match => !match.IsCause).Take(maximumLines - causes.Count)) + .OrderBy(match => match.Order) + .Select(match => match.Line) + .ToList(); } private static string NormalizeSingleLine(string value) @@ -123,11 +132,21 @@ private static string NormalizeSingleLine(string value) return builder.ToString(); } + /// Markers that name a failure's cause rather than a consequence of it. + private const string CauseMarkers = + "CUDA error|cudaError|failed to allocate|out of memory|error loading model|unable to load model"; + [GeneratedRegex( - @"CUDA error|cudaError|exited with status|failed to load|failed to allocate|out of memory|error loading model|unable to load model", + CauseMarkers + "|exited with status|failed to load", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex DiagnosticPattern(); + [GeneratedRegex(CauseMarkers, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CausePattern(); + + [GeneratedRegex(@"^\[\d+\]\s*", RegexOptions.CultureInvariant)] + private static partial Regex InstancePrefixPattern(); + [GeneratedRegex( @"(?:\blog_server_[a-z_]*|\brequest|\bresponse)\s*:", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index d4717669c3..eda17ebb4d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7630,6 +7630,7 @@ Make sure the gateway is running. Setting up Local AI The Gateway or Local AI readiness changed. Go back to Connect your AI and check again before installing. Local AI could not start. Cleanup is complete. Repair Local AI or choose another provider. + Open Local AI logs Checking this PC. You can still choose an AI provider. Compatible PC. Review setup before installing. Installed, not running diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 43decec8ac..7c989bb8d2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -7565,6 +7565,7 @@ Le binaire wxc-exec est introuvable. {1} S'il s'agit d'une build développeur, c Configuration de l’IA locale Le Gateway ou la disponibilité de l’IA locale a changé. Revenez à la connexion de votre IA et vérifiez à nouveau avant l’installation. L’IA locale n’a pas pu démarrer. Le nettoyage est terminé. Réparez l’IA locale ou choisissez un autre fournisseur. + Ouvrir les journaux de l’IA locale Vérification de ce PC. Vous pouvez toujours choisir un fournisseur d’IA. PC compatible. Vérifiez la configuration avant l’installation. Installé, à l’arrêt diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 636d17da3a..fac941c402 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -7566,6 +7566,7 @@ Het binaire bestand wxc-exec is niet gevonden. {1} Als dit een ontwikkelaarsbuil Lokale AI instellen De Gateway of de status van lokale AI is gewijzigd. Ga terug naar AI verbinden en controleer opnieuw voordat je installeert. Lokale AI kon niet starten. Het opruimen is voltooid. Herstel lokale AI of kies een andere provider. + Logboeken van lokale AI openen Deze pc wordt gecontroleerd. Je kunt nog steeds een AI-provider kiezen. Compatibele pc. Controleer de instellingen vóór installatie. Geïnstalleerd, niet actief diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index d197a1bff2..2eecedb9ed 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -7619,6 +7619,7 @@ O binário wxc-exec não pôde ser localizado. {1} Se esta for uma build de dese Configurando a IA local O Gateway ou a disponibilidade da IA local mudou. Volte à conexão da IA e verifique novamente antes de instalar. A IA local não pôde iniciar. A limpeza foi concluída. Repare a IA local ou escolha outro provedor. + Abrir logs da IA local Verificando este PC. Você ainda pode escolher um provedor de IA. PC compatível. Revise a configuração antes de instalar. Instalado, não está em execução diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index d3472bb5ea..2757552827 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -7565,6 +7565,7 @@ 正在设置本地 AI Gateway 或本地 AI 的就绪状态已更改。请返回连接 AI 页面并重新检查,然后再安装。 本地 AI 无法启动。清理已完成。请修复本地 AI 或选择其他提供商。 + 打开本地 AI 日志 正在检查此电脑。你仍可选择 AI 提供商。 电脑兼容。安装前请查看设置。 已安装,未运行 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index b95c82829f..79c6f15227 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -7565,6 +7565,7 @@ 正在設定本機 AI Gateway 或本機 AI 的就緒狀態已變更。請返回連線 AI 頁面並重新檢查,然後再安裝。 本機 AI 無法啟動。清理已完成。請修復本機 AI 或選擇其他提供者。 + 開啟本機 AI 記錄 正在檢查此電腦。你仍可選擇 AI 提供者。 電腦相容。安裝前請檢閱設定。 已安裝,未執行 diff --git a/tests/OpenClaw.SetupEngine.Tests/LocalAiLogTailTests.cs b/tests/OpenClaw.SetupEngine.Tests/LocalAiLogTailTests.cs index 44c863ab93..7fee7185fd 100644 --- a/tests/OpenClaw.SetupEngine.Tests/LocalAiLogTailTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/LocalAiLogTailTests.cs @@ -68,6 +68,27 @@ srv operator(): instance name=qwen3.6-27b-mtp-q4-k-m exited with status -1073 private const string ModelFormatFailureLog = "llama_model_load: error loading model architecture: unknown model architecture"; + /// + /// Abridged from a real Nemotron Bolt failure on Windows ARM64: the router retried the load, + /// each instance (new "[pid]") failed on the LiLiCorr draft model, and the router's stderr + /// consequences follow the stdout tail. A plain last-4 window showed only consequences and + /// dropped the tensor mismatch that names the cause. + /// + private const string RepeatedDraftLoadFailureLog = + """ + [53805] llama_model_load: error loading model: check_tensor_dims: tensor 'lilicorr.feature_mlp.0.weight' not found + [53805] llama_model_load_from_file_impl: failed to load model + [53805] common_speculative_init_result: failed to load draft model, 'draft.gguf' + [53805] srv load_model: failed to load draft model, 'draft.gguf' + [56741] llama_model_load: error loading model: check_tensor_dims: tensor 'lilicorr.feature_mlp.0.weight' not found + [56741] llama_model_load_from_file_impl: failed to load model + [56741] common_speculative_init_result: failed to load draft model, 'draft.gguf' + [56741] srv load_model: failed to load draft model, 'draft.gguf' + + srv run: instance name=nemotron-3.5-bolt-74b-a9b-iq2-lilicorr exited with status 1 + srv operator (): got exception: {"error":{"code":500,"message":"model name=nemotron-3.5-bolt-74b-a9b-iq2-lilicorr failed to load","type":"server_error"}} + """; + [Fact] public void ExtractDiagnosticLines_ReturnsCudaAndExitStatusLines() { @@ -123,6 +144,20 @@ public void ExtractDiagnosticLines_ReturnsModelFormatFailure() Assert.Contains(lines, line => line.Contains("error loading model", StringComparison.Ordinal)); } + [Fact] + public void ExtractDiagnosticLines_KeepsCauseAheadOfLaterConsequencesAndCollapsesRetries() + { + IReadOnlyList lines = LocalAiLogTail.ExtractDiagnosticLines(RepeatedDraftLoadFailureLog); + + Assert.Equal( + [ + "[56741] llama_model_load: error loading model: check_tensor_dims: tensor 'lilicorr.feature_mlp.0.weight' not found", + "[56741] srv load_model: failed to load draft model, 'draft.gguf'", + "srv run: instance name=nemotron-3.5-bolt-74b-a9b-iq2-lilicorr exited with status 1", + "srv operator (): got exception: {\"error\":{\"code\":500,\"message\":\"model name=nemotron-3.5-bolt-74b-a9b-iq2-lilicorr failed to load\",\"type\":\"server_error\"}}", + ], lines); + } + [Fact] public async Task ReadTailAsync_ReadsBoundedTailWhileFileIsOpenForWriting() { diff --git a/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs index 5c3b30579b..f7194b8413 100644 --- a/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalAiOnboardingOwnershipTests.cs @@ -72,7 +72,7 @@ public void NativeInstallAndUse_TransfersReviewedConsentWithoutReturningToDiscov Assert.Contains("LocalAiSelectionRejectedException or LocalAiStartFailedException", recovery); Assert.Contains("_localObservation!.RefreshAsync", recovery); Assert.Contains("TitleText.Text = S(\"Title.Text\")", recovery); - Assert.Contains("S(\"LocalStartFailed\") + \" \" + ex.Message", recovery); + Assert.Contains("ShowLocalAiStartFailure(startFailed)", recovery); Assert.Contains("await DetectAsync(ct)", recovery); Assert.DoesNotContain("DetectAsync", continuation[recoveryEnd..]); var navigation = page[page.IndexOf("protected override void OnNavigatedTo", StringComparison.Ordinal).. @@ -151,7 +151,7 @@ public void NativeOwnershipDiscoveryIsReadOnlyAndGuidanceIsLocalized() { var resources = System.Xml.Linq.XDocument.Parse(Read($@"src\OpenClaw.Tray.WinUI\Strings\{locale}\Resources.resw")); foreach (var key in new[] { "LocalState_ManagementBlocked", "LocalOwnershipMissing", "LocalOwnershipInvalid", - "LocalOwnershipUnavailable", "LocalOwnershipFiles" }) + "LocalOwnershipUnavailable", "LocalOwnershipFiles", "OpenLocalAiLogs" }) { var value = Assert.Single(resources.Descendants("data"), element => (string?)element.Attribute("name") == "Onboarding_AiSetup_" + key).Element("value")!.Value; diff --git a/tests/OpenClaw.Tray.UITests/OnboardingAiPageTests.cs b/tests/OpenClaw.Tray.UITests/OnboardingAiPageTests.cs index 6802aee5bc..8b08a367dc 100644 --- a/tests/OpenClaw.Tray.UITests/OnboardingAiPageTests.cs +++ b/tests/OpenClaw.Tray.UITests/OnboardingAiPageTests.cs @@ -1391,6 +1391,81 @@ await WithPageAsync(async (page, _, completed) => }, localAiHost: host, reviewLocalAi: _ => Task.CompletedTask); } + private const string SyntheticLoadFailure = + "llama-server: llama_model_load: error loading model: synthetic tensor shape mismatch"; + + [Fact] + public async Task LocalAi_VerificationFailureShowsLlamaServerDiagnosticsAndLogsLink() + { + var log = WriteLlamaServerFailureLog(); + try + { + var host = new PageLocalAiHost(LocalAiOnboardingState.Use); + await WithPageAsync(async (page, _, completed) => + { + await InvokeLocalAiAsync(page); + await WaitAsync(() => Find(page, "LocalAiFailurePanel").Visibility == Visibility.Visible); + var diagnostics = Find(page, "LocalAiDiagnosticsText").Text; + Assert.Contains(SyntheticLoadFailure, diagnostics); + Assert.DoesNotContain("load_tensors", diagnostics); + Assert.Equal(Visibility.Visible, Find(page, "OpenLocalAiLogsButton").Visibility); + Assert.Contains("Synthetic verification failure.", Find(page, "ErrorBar").Message); + Assert.Equal(0, completed()); + }, localAiHost: host, configure: transport => transport.FailVerification = true); + } + finally { File.Delete(log); } + } + + [Fact] + public async Task LocalAi_StartFailureShowsLlamaServerDiagnosticsAndLogsLink() + { + var log = WriteLlamaServerFailureLog(); + try + { + var host = new PageLocalAiHost(LocalAiOnboardingState.StartAndUse) + { UseFailure = new LocalAiStartFailedException("Synthetic start failure.") }; + await WithPageAsync(async (page, _, completed) => + { + await InvokeLocalAiAsync(page); + await WaitAsync(() => Find(page, "LocalAiFailurePanel").Visibility == Visibility.Visible); + Assert.Contains(SyntheticLoadFailure, Find(page, "LocalAiDiagnosticsText").Text); + Assert.Equal(Visibility.Visible, Find(page, "OpenLocalAiLogsButton").Visibility); + Assert.Contains("Synthetic start failure.", Find(page, "ErrorBar").Message); + Assert.Equal(0, completed()); + }, localAiHost: host, reviewLocalAi: _ => Task.CompletedTask); + } + finally { File.Delete(log); } + } + + [Fact] + public async Task ConfiguredModelVerificationFailure_DoesNotShowLocalAiDiagnostics() + { + var log = WriteLlamaServerFailureLog(); + try + { + await WithPageAsync(async (page, _, completed) => + { + Assert.True(Find(page, "ErrorBar").IsOpen); + await ui.YieldToRenderAsync(); + await ui.YieldToRenderAsync(); + Assert.Equal(Visibility.Collapsed, Find(page, "LocalAiFailurePanel").Visibility); + Assert.Equal(0, completed()); + }, expectedModelRef: "openai/test-model", configure: transport => transport.FailVerification = true); + } + finally { File.Delete(log); } + } + + private static string WriteLlamaServerFailureLog() + { + var data = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") + ?? throw new InvalidOperationException("Set isolated tray data before onboarding UI tests."); + var paths = new OpenClaw.Connection.LocalAi.LocalAiPaths(data); + Directory.CreateDirectory(paths.LogsDirectory); + File.WriteAllText(paths.StandardErrorLogPath, + "load_tensors: loading model tensors\nllama_model_load: error loading model: synthetic tensor shape mismatch\n"); + return paths.StandardErrorLogPath; + } + [Fact] public async Task LocalAi_ReadinessTimeoutReportsCardStateWithoutInvoking() {