From e74df494d5532f83a124069634d1819023c19930 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Fri, 10 Jul 2026 18:15:20 -0700 Subject: [PATCH 1/2] 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 2/2] 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);