From 901592a619c92b18a13d5ad5fb6799b0f9a86a34 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Fri, 10 Jul 2026 17:20:31 -0700 Subject: [PATCH 1/4] fix(serve): persist running->ready promotion in CLI liveness refresh refresh_managed_service_runtime_liveness() ran the real HTTP model-ready probe but only used a passing result to skip demotion, never to persist the running->ready transition. Its twin in providers.rs::ready_local_services() already promotes correctly; mirror that here so load_managed_services() (and therefore the "services" MCP tool and chat's pick_managed_chat_endpoint, which requires exact status "ready") see the true state instead of a manifest stuck at "running" forever. Relates to EAI-7352 Signed-off-by: Michael Roy --- apps/rocm/src/main.rs | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index c3fc1db8..577f309a 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -13082,6 +13082,12 @@ fn refresh_managed_service_runtime_liveness(record: &mut ManagedServiceRecord) - && managed_service_endpoint_model_ready(record, SERVICE_LIVENESS_CHECK_TIMEOUT) .unwrap_or(false); if endpoint_ready { + // Mirror `providers.rs::ready_local_services()`: a live, probe-passing + // service should be persisted as "ready", not left stuck at "running". + if record.status == "running" { + record.status = "ready".to_owned(); + return true; + } return false; } @@ -16096,6 +16102,81 @@ mod tests { Ok(()) } + #[test] + fn load_managed_services_promotes_running_to_ready_once_probe_passes() -> Result<()> { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + // Two probes hit this mock: one from `load_managed_services`, another + // from the `load_managed_service` re-read below that verifies the + // promotion was actually persisted, not just returned in-memory. + let server = thread::spawn(move || -> Result<()> { + for _ in 0..2 { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let mut request_bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream.read(&mut buffer)?; + if read == 0 { + break; + } + request_bytes.extend_from_slice(&buffer[..read]); + if String::from_utf8_lossy(&request_bytes).contains("\r\n\r\n") { + break; + } + } + let body = r#"{"data":[{"id":"Qwen3-0.6B-GGUF"}]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + )?; + } + Ok(()) + }); + + let (root, paths) = test_paths("load-managed-services-promote-ready"); + paths.ensure()?; + let mut record = ManagedServiceRecord::new( + &paths, + "svc-qwen-promote", + "vllm", + "Qwen3-0.6B-GGUF", + "Qwen3-0.6B-GGUF", + "127.0.0.1", + port, + "managed", + std::process::id(), + None, + None, + None, + ); + // A supervisor that has already observed the engine come up reports + // "running"; only the HTTP model-ready probe should promote it further. + record.status = "running".to_owned(); + record.write()?; + + let records = load_managed_services(&paths)?; + let promoted = records + .iter() + .find(|found| found.service_id == "svc-qwen-promote") + .expect("service should be present"); + assert_eq!(promoted.status, "ready"); + + // The promotion must have been persisted to disk, not just returned + // in-memory, since chat's `pick_managed_chat_endpoint` re-reads it. + let reloaded = load_managed_service(&paths, "svc-qwen-promote")?; + assert_eq!(reloaded.status, "ready"); + + server.join().expect("server thread should not panic")?; + fs::remove_dir_all(root).ok(); + Ok(()) + } + fn test_examine(os: &str, wsl: bool) -> ExamineSummary { ExamineSummary { os: os.to_owned(), From e74df494d5532f83a124069634d1819023c19930 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Fri, 10 Jul 2026 18:15:20 -0700 Subject: [PATCH 2/4] feat(chat): explain why a reply is stalled when the endpoint isn't ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a message to a local endpoint whose model was still coming up showed only a generic "waiting for the agent…" spinner, indistinguishable from a slow reply or a hang. The chat input never consulted the readiness of the instance it was pointed at. Match the chat endpoint to its daemon-surfaced instance by port and, when that instance is not answering, replace the generic spinner with a specific reason: the model is still starting up, the endpoint has stopped, or it reported an error. A live instance, a remote gateway (no local instance to inspect), or an unknown state falls back to the plain spinner unchanged. Parsing and the reason mapping are pure, unit-tested helpers (port_from_base_url, chat_backend_wait_reason). Stacked on #101 (EAI-7352). Relates to EAI-7348 Signed-off-by: Michael Roy --- crates/rocm-dash-tui/src/ui/tabs/chat.rs | 150 +++++++++++++++++++++-- 1 file changed, 137 insertions(+), 13 deletions(-) diff --git a/crates/rocm-dash-tui/src/ui/tabs/chat.rs b/crates/rocm-dash-tui/src/ui/tabs/chat.rs index e7d8290c..02a3e5fa 100644 --- a/crates/rocm-dash-tui/src/ui/tabs/chat.rs +++ b/crates/rocm-dash-tui/src/ui/tabs/chat.rs @@ -14,6 +14,8 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Wrap}; +use rocm_dash_core::metrics::InstanceStatus; + use crate::app::{AppState, ChatConsent, ChatRole}; use crate::ui::panel::{self, BoxRole}; use crate::ui::theme::Theme; @@ -273,22 +275,49 @@ pub fn transcript_lines<'a>(state: &'a AppState, theme: &Theme) -> Vec> /// Render the single-row input line. While focused, a caret glyph trails the /// buffer; otherwise a muted hint invites focus. +/// Parse the TCP port out of a chat endpoint base URL +/// (`http://127.0.0.1:8000/v1` → `8000`). `None` when no explicit port is +/// present (e.g. a bare host or a remote gateway URL). +fn port_from_base_url(base_url: &str) -> Option { + let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url); + let authority = after_scheme.split('/').next().unwrap_or(after_scheme); + authority.rsplit(':').next()?.parse().ok() +} + +/// If the local instance backing the chat endpoint is not yet answering, +/// explain why so an in-flight request reads as "the model is still coming up" +/// rather than an unexplained spinner. Matched to a daemon-surfaced instance by +/// port; returns `None` when the endpoint looks live, is remote, or isn't a +/// managed instance we can see (caller then shows the plain spinner). +fn chat_backend_wait_reason(state: &AppState) -> Option { + let base_url = &state.chat_llm.as_ref()?.base_url; + let port = port_from_base_url(base_url)?; + let inst = state.instances.values().find(|i| i.port == Some(port))?; + match inst.status { + InstanceStatus::Running => None, + InstanceStatus::Starting => Some("the model is still starting up — hang tight".to_owned()), + InstanceStatus::Stopped => { + Some("the endpoint has stopped — restart the service to chat".to_owned()) + } + InstanceStatus::Error => { + Some("the endpoint reported an error — check the service logs".to_owned()) + } + InstanceStatus::Unknown => None, + } +} + fn draw_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { // While a request is in flight, show a spinner and suppress the caret — - // input is disabled until the reply or error turn lands. + // input is disabled until the reply or error turn lands. When the backing + // endpoint isn't ready yet, say so instead of an unexplained spinner. if state.chat_sending { - let inner = panel::bento( - f, - area, - Some("Message (sending…)"), - BoxRole::Warning, - false, - theme, - ); - let line = Line::from(Span::styled( - "⠿ waiting for the agent…", - Style::default().fg(theme.muted), - )); + let reason = chat_backend_wait_reason(state); + let (title, body): (&str, String) = match &reason { + Some(reason) => ("Message (waiting on the model)", format!("⠿ {reason}")), + None => ("Message (sending…)", "⠿ waiting for the agent…".to_owned()), + }; + let inner = panel::bento(f, area, Some(title), BoxRole::Warning, false, theme); + let line = Line::from(Span::styled(body, Style::default().fg(theme.muted))); f.render_widget(Paragraph::new(line), inner); return; } @@ -439,4 +468,99 @@ mod tests { assert!(out.contains("use now")); assert!(out.contains("use & save")); } + + #[test] + fn port_from_base_url_parses_local_endpoints() { + assert_eq!(port_from_base_url("http://127.0.0.1:8000/v1"), Some(8000)); + assert_eq!(port_from_base_url("http://localhost:11435"), Some(11435)); + // Remote gateway (no explicit port) and a bare host yield None. + assert_eq!(port_from_base_url("https://gateway.example.com/v1"), None); + assert_eq!(port_from_base_url("http://127.0.0.1"), None); + } + + fn accepted_chat_at_port(port: u16) -> AppState { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + let llm = crate::llm::LlmConfig { + base_url: format!("http://127.0.0.1:{port}/v1"), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm), true); + s + } + + fn set_backing_instance(s: &mut AppState, port: u16, status: InstanceStatus) { + use rocm_dash_core::metrics::Instance; + s.instances.clear(); + s.instances.insert( + "svc".into(), + Instance { + container_id: "svc".into(), + port: Some(port), + status, + ..Default::default() + }, + ); + } + + #[test] + fn backend_wait_reason_reflects_instance_status() { + let mut s = accepted_chat_at_port(8000); + // No instance visible → no endpoint-specific reason (falls back to spinner). + assert_eq!(chat_backend_wait_reason(&s), None); + + set_backing_instance(&mut s, 8000, InstanceStatus::Starting); + assert!( + chat_backend_wait_reason(&s) + .unwrap() + .contains("starting up") + ); + set_backing_instance(&mut s, 8000, InstanceStatus::Stopped); + assert!(chat_backend_wait_reason(&s).unwrap().contains("stopped")); + set_backing_instance(&mut s, 8000, InstanceStatus::Error); + assert!(chat_backend_wait_reason(&s).unwrap().contains("error")); + // A live instance needs no explanation. + set_backing_instance(&mut s, 8000, InstanceStatus::Running); + assert_eq!(chat_backend_wait_reason(&s), None); + // A mismatched port is not our endpoint. + set_backing_instance(&mut s, 9999, InstanceStatus::Starting); + assert_eq!(chat_backend_wait_reason(&s), None); + } + + #[test] + fn sending_input_surfaces_startup_reason_not_generic_spinner() { + let mut s = accepted_chat_at_port(8000); + s.chat_sending = true; + set_backing_instance(&mut s, 8000, InstanceStatus::Starting); + let out = render_str(&s); + assert!( + out.contains("starting up"), + "shows the specific startup reason" + ); + assert!( + !out.contains("waiting for the agent"), + "not the generic spinner" + ); + } + + #[test] + fn sending_input_falls_back_to_generic_spinner_for_remote_endpoint() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + let llm = crate::llm::LlmConfig { + base_url: "https://gateway.example.com/v1".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm), true); + s.chat_sending = true; + let out = render_str(&s); + assert!( + out.contains("waiting for the agent"), + "generic spinner remains" + ); + } } From a068711f025ee0229b458fed4fe944ef961ded98 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Fri, 10 Jul 2026 21:04:38 -0700 Subject: [PATCH 3/4] fix(chat): match the readiness-reason endpoint by host+port, not port alone chat_backend_wait_reason matched the chat endpoint to a daemon-surfaced instance by TCP port only. Daemon-tracked instances are always co-located (scraped over loopback), but a remote gateway URL that happens to share a port number with a local managed service -- e.g. both on 8000 -- would false-match that unrelated local instance and could wrongly show a "still starting up" reason for a perfectly healthy remote endpoint. Add host_from_base_url and only attempt the match when the endpoint's host is loopback (reusing llm::is_loopback_host, now pub(crate)); a non-loopback host always falls back to the generic spinner regardless of port. Added host_from_base_url unit tests and a backend_wait_reason_ignores_remote_endpoint_sharing_a_local_port_number test exercising the exact false-match scenario (remote host, same port as a Starting local instance) to prove the generic spinner is kept. Regenerated THIRD_PARTY_NOTICES.txt (pre-existing ordering drift, unrelated to this change). Relates to EAI-7348 Signed-off-by: Michael Roy --- THIRD_PARTY_NOTICES.txt | 2 +- crates/rocm-dash-tui/src/llm.rs | 2 +- crates/rocm-dash-tui/src/ui/tabs/chat.rs | 63 +++++++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 6f051d11..2988c8f6 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -1725,7 +1725,6 @@ Apache License 2.0 The following component(s) are licensed under Apache License 2.0: - rustls-platform-verifier 0.7.0 - - ureq 2.12.1 Apache License Version 2.0, January 2004 @@ -5642,6 +5641,7 @@ The following component(s) are licensed under Apache License 2.0: - unicode-truncate 2.0.1 - unicode-width 0.1.14 - unicode-width 0.2.0 + - ureq 2.12.1 - url 2.5.8 - uuid 1.23.3 - wasi 0.11.1+wasi-snapshot-preview1 diff --git a/crates/rocm-dash-tui/src/llm.rs b/crates/rocm-dash-tui/src/llm.rs index 8c449be2..be33895d 100644 --- a/crates/rocm-dash-tui/src/llm.rs +++ b/crates/rocm-dash-tui/src/llm.rs @@ -165,7 +165,7 @@ pub fn parse_host_port(base_url: &str) -> Option<(String, u16)> { /// brackets from a bracketed IPv6 authority. Matches `localhost` /// (case-insensitive), the IPv6 loopback (`::1`), and any `127.0.0.0/8` IPv4 /// address. -fn is_loopback_host(host: &str) -> bool { +pub(crate) fn is_loopback_host(host: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host == "::1" || host.starts_with("127.") } diff --git a/crates/rocm-dash-tui/src/ui/tabs/chat.rs b/crates/rocm-dash-tui/src/ui/tabs/chat.rs index 02a3e5fa..9e7cb572 100644 --- a/crates/rocm-dash-tui/src/ui/tabs/chat.rs +++ b/crates/rocm-dash-tui/src/ui/tabs/chat.rs @@ -284,14 +284,33 @@ fn port_from_base_url(base_url: &str) -> Option { authority.rsplit(':').next()?.parse().ok() } +/// Parse the host out of a chat endpoint base URL +/// (`http://127.0.0.1:8000/v1` → `127.0.0.1`, `https://gateway.example.com` → +/// `gateway.example.com`). `None` only for a malformed/empty authority. +fn host_from_base_url(base_url: &str) -> Option { + let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url); + let authority = after_scheme.split('/').next().unwrap_or(after_scheme); + let host = authority + .rsplit_once(':') + .map_or(authority, |(host, _)| host); + (!host.is_empty()).then(|| host.to_owned()) +} + /// If the local instance backing the chat endpoint is not yet answering, /// explain why so an in-flight request reads as "the model is still coming up" /// rather than an unexplained spinner. Matched to a daemon-surfaced instance by -/// port; returns `None` when the endpoint looks live, is remote, or isn't a -/// managed instance we can see (caller then shows the plain spinner). +/// host + port (daemon-tracked instances are always co-located, so only a +/// loopback endpoint can be one of them — a remote gateway must never +/// false-match just because it happens to share a port number with a local +/// instance); returns `None` when the endpoint looks live, is remote, or +/// isn't a managed instance we can see (caller then shows the plain spinner). fn chat_backend_wait_reason(state: &AppState) -> Option { let base_url = &state.chat_llm.as_ref()?.base_url; let port = port_from_base_url(base_url)?; + let host = host_from_base_url(base_url)?; + if !crate::llm::is_loopback_host(&host) { + return None; + } let inst = state.instances.values().find(|i| i.port == Some(port))?; match inst.status { InstanceStatus::Running => None, @@ -478,6 +497,26 @@ mod tests { assert_eq!(port_from_base_url("http://127.0.0.1"), None); } + #[test] + fn host_from_base_url_parses_local_and_remote_endpoints() { + assert_eq!( + host_from_base_url("http://127.0.0.1:8000/v1"), + Some("127.0.0.1".to_owned()) + ); + assert_eq!( + host_from_base_url("http://localhost:11435"), + Some("localhost".to_owned()) + ); + assert_eq!( + host_from_base_url("https://gateway.example.com:8000/v1"), + Some("gateway.example.com".to_owned()) + ); + assert_eq!( + host_from_base_url("http://127.0.0.1"), + Some("127.0.0.1".to_owned()) + ); + } + fn accepted_chat_at_port(port: u16) -> AppState { let mut s = AppState::new("t".into(), "default-dark".into()); s.active_tab = crate::app::ActiveTab::Chat; @@ -529,6 +568,26 @@ mod tests { assert_eq!(chat_backend_wait_reason(&s), None); } + #[test] + fn backend_wait_reason_ignores_remote_endpoint_sharing_a_local_port_number() { + // A remote gateway can happen to use the same port number (e.g. 8000) + // as a local daemon-tracked instance. Matching by port alone would + // wrongly borrow that unrelated instance's status; matching requires + // the endpoint's host to be loopback, so a remote host must fall back + // to the generic spinner even when a same-port local instance exists. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + let llm = crate::llm::LlmConfig { + base_url: "https://gateway.example.com:8000/v1".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm), true); + set_backing_instance(&mut s, 8000, InstanceStatus::Starting); + assert_eq!(chat_backend_wait_reason(&s), None); + } + #[test] fn sending_input_surfaces_startup_reason_not_generic_spinner() { let mut s = accepted_chat_at_port(8000); From 72391ae619bf3875042d09e10cd3d669a38af9ce Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Tue, 14 Jul 2026 15:52:12 -0700 Subject: [PATCH 4/4] fix(chat): parse IPv6 loopback endpoints via the shared authority parser The readiness-reason helper hand-rolled two authority parsers (port_from_base_url / host_from_base_url) with rsplit(':'), which mangles a bracketed IPv6 loopback: host_from_base_url("http://[::1]:8000") kept the brackets so is_loopback_host("[::1]") was false, and port_from_base_url("http://[::1]/v1") parsed "1]" and failed. Either path silently disabled chat_backend_wait_reason for a legitimate local IPv6 endpoint. Replace both helpers with the crate's existing parse_host_port, which already strips IPv6 brackets, defaults the port from the scheme, and is pinned by parse_host_port_handles_bracketed_ipv6. Drop the now-redundant per-helper unit tests (covered by the llm.rs suite) and add a bracketed-IPv6 regression test against chat_backend_wait_reason. Also tighten the doc comment: Running is not a hard HTTP-readiness guarantee, and record the #106/#107 status-signal dependency in-tree so the Starting/Stopped/Error arms are discoverable as pending until that work lands beneath this change. Addresses pr-review-watcher blocking finding #2 (IPv6) and the doc / test-coverage non-blocking notes on #108. Signed-off-by: Michael Roy --- crates/rocm-dash-tui/src/ui/tabs/chat.rs | 94 ++++++++++-------------- 1 file changed, 38 insertions(+), 56 deletions(-) diff --git a/crates/rocm-dash-tui/src/ui/tabs/chat.rs b/crates/rocm-dash-tui/src/ui/tabs/chat.rs index 9e7cb572..afcf947f 100644 --- a/crates/rocm-dash-tui/src/ui/tabs/chat.rs +++ b/crates/rocm-dash-tui/src/ui/tabs/chat.rs @@ -273,41 +273,26 @@ pub fn transcript_lines<'a>(state: &'a AppState, theme: &Theme) -> Vec> lines } -/// Render the single-row input line. While focused, a caret glyph trails the -/// buffer; otherwise a muted hint invites focus. -/// Parse the TCP port out of a chat endpoint base URL -/// (`http://127.0.0.1:8000/v1` → `8000`). `None` when no explicit port is -/// present (e.g. a bare host or a remote gateway URL). -fn port_from_base_url(base_url: &str) -> Option { - let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url); - let authority = after_scheme.split('/').next().unwrap_or(after_scheme); - authority.rsplit(':').next()?.parse().ok() -} - -/// Parse the host out of a chat endpoint base URL -/// (`http://127.0.0.1:8000/v1` → `127.0.0.1`, `https://gateway.example.com` → -/// `gateway.example.com`). `None` only for a malformed/empty authority. -fn host_from_base_url(base_url: &str) -> Option { - let after_scheme = base_url.split("://").nth(1).unwrap_or(base_url); - let authority = after_scheme.split('/').next().unwrap_or(after_scheme); - let host = authority - .rsplit_once(':') - .map_or(authority, |(host, _)| host); - (!host.is_empty()).then(|| host.to_owned()) -} - /// If the local instance backing the chat endpoint is not yet answering, /// explain why so an in-flight request reads as "the model is still coming up" /// rather than an unexplained spinner. Matched to a daemon-surfaced instance by /// host + port (daemon-tracked instances are always co-located, so only a /// loopback endpoint can be one of them — a remote gateway must never /// false-match just because it happens to share a port number with a local -/// instance); returns `None` when the endpoint looks live, is remote, or -/// isn't a managed instance we can see (caller then shows the plain spinner). +/// instance). +/// +/// Returns `None` when the endpoint is remote, isn't a managed instance we can +/// see, or the instance is serving/unknown — the caller then shows the plain +/// spinner. Note `Running` is not a hard HTTP-readiness guarantee: the daemon +/// collapses `ready`/`running`/`starting` into `Running` today, so the +/// `Starting`/`Stopped`/`Error` reasons below only reach production once the +/// status-signal work (#106 EAI-7354, #107 EAI-7355) lands beneath this change. fn chat_backend_wait_reason(state: &AppState) -> Option { let base_url = &state.chat_llm.as_ref()?.base_url; - let port = port_from_base_url(base_url)?; - let host = host_from_base_url(base_url)?; + // Reuse the crate's authority parser: it strips the brackets from an IPv6 + // loopback (`http://[::1]:8000` → host `::1`) that a hand-rolled + // `rsplit(':')` would mangle, and defaults the port from the scheme. + let (host, port) = crate::llm::parse_host_port(base_url)?; if !crate::llm::is_loopback_host(&host) { return None; } @@ -325,6 +310,8 @@ fn chat_backend_wait_reason(state: &AppState) -> Option { } } +/// Render the single-row input line. While focused, a caret glyph trails the +/// buffer; otherwise a muted hint invites focus. fn draw_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { // While a request is in flight, show a spinner and suppress the caret — // input is disabled until the reply or error turn lands. When the backing @@ -488,35 +475,6 @@ mod tests { assert!(out.contains("use & save")); } - #[test] - fn port_from_base_url_parses_local_endpoints() { - assert_eq!(port_from_base_url("http://127.0.0.1:8000/v1"), Some(8000)); - assert_eq!(port_from_base_url("http://localhost:11435"), Some(11435)); - // Remote gateway (no explicit port) and a bare host yield None. - assert_eq!(port_from_base_url("https://gateway.example.com/v1"), None); - assert_eq!(port_from_base_url("http://127.0.0.1"), None); - } - - #[test] - fn host_from_base_url_parses_local_and_remote_endpoints() { - assert_eq!( - host_from_base_url("http://127.0.0.1:8000/v1"), - Some("127.0.0.1".to_owned()) - ); - assert_eq!( - host_from_base_url("http://localhost:11435"), - Some("localhost".to_owned()) - ); - assert_eq!( - host_from_base_url("https://gateway.example.com:8000/v1"), - Some("gateway.example.com".to_owned()) - ); - assert_eq!( - host_from_base_url("http://127.0.0.1"), - Some("127.0.0.1".to_owned()) - ); - } - fn accepted_chat_at_port(port: u16) -> AppState { let mut s = AppState::new("t".into(), "default-dark".into()); s.active_tab = crate::app::ActiveTab::Chat; @@ -568,6 +526,30 @@ mod tests { assert_eq!(chat_backend_wait_reason(&s), None); } + #[test] + fn backend_wait_reason_matches_bracketed_ipv6_loopback() { + // A bracketed IPv6 loopback endpoint must still resolve to its backing + // instance: the shared authority parser strips the brackets so + // `is_loopback_host` sees `::1`. A hand-rolled `rsplit(':')` used to + // mangle this (`[::1]` host / `1]` port) and silently disable the reason. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = crate::app::ActiveTab::Chat; + let llm = crate::llm::LlmConfig { + base_url: "http://[::1]:8000/v1".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm), true); + set_backing_instance(&mut s, 8000, InstanceStatus::Starting); + assert!( + chat_backend_wait_reason(&s) + .unwrap() + .contains("starting up"), + "bracketed IPv6 loopback should resolve to its backing instance" + ); + } + #[test] fn backend_wait_reason_ignores_remote_endpoint_sharing_a_local_port_number() { // A remote gateway can happen to use the same port number (e.g. 8000)