Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 158 additions & 3 deletions crates/rocm-dash-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,28 +1002,58 @@ impl AppState {
/// `chat_detect_dispatch` edge so `event_loop` runs the probe + `/v1/models`
/// query off the reducer. No-op while a probe is already in flight or an
/// offer is awaiting a decision. I/O-free.
///
/// Reachable both from the pre-accept gate (`'d'` key) and, once
/// `ChatConsent::Accepted`, from the `/detect` slash command (a focused
/// `'d'` keypress is ordinary chat text at that point, so the gate key
/// doesn't apply there — see `handle_slash_command`). When already
/// accepted, echo the in-flight probe into the transcript since the
/// pre-accept "detecting…" banner isn't drawn once chat is live.
pub fn request_detect(&mut self) {
if self.chat_detecting || self.chat_detect_offer.is_some() {
return;
}
self.chat_detecting = true;
self.chat_detect_msg = None;
self.chat_detect_dispatch = true;
if self.chat_consent == ChatConsent::Accepted {
self.chat.push(ChatTurn::agent(
"Detecting a local engine (Lemonade :13305 / vLLM :8000 / rocm serve :11435)…"
.to_string(),
));
}
}

/// Record the result of a detect attempt: `Some(cfg)` raises the offer
/// prompt; `None` records a "nothing found" message. Clears the in-flight
/// flag either way.
///
/// Once `ChatConsent::Accepted`, the offer prompt is not drawn (the gate UI
/// only renders pre-accept), so the result is also echoed into the
/// transcript with the `/detect accept|save|dismiss` sub-commands needed to
/// act on it.
pub fn set_detect_result(&mut self, offer: Option<crate::llm::LlmConfig>) {
self.chat_detecting = false;
let accepted = self.chat_consent == ChatConsent::Accepted;
if let Some(cfg) = offer {
self.chat_detect_msg = None;
if accepted {
self.chat.push(ChatTurn::agent(format!(
"Detected a local engine: {} (model: {}). Type `/detect accept` to \
switch now, `/detect save` to also persist it, or `/detect dismiss` \
to ignore.",
cfg.base_url, cfg.model
)));
}
self.chat_detect_offer = Some(cfg);
} else {
self.chat_detect_offer = None;
self.chat_detect_msg = Some(
"no local engine found (Lemonade :13305 / vLLM :8000 / rocm serve :11435)".into(),
);
let msg = "no local engine found (Lemonade :13305 / vLLM :8000 / rocm serve :11435)";
if accepted {
self.chat.push(ChatTurn::agent(msg.to_string()));
} else {
self.chat_detect_msg = Some(msg.to_string());
}
}
}

Expand Down Expand Up @@ -4105,6 +4135,131 @@ mod tests {
assert!(s.chat_detect_msg.is_some());
}

/// EAI-7354: once `ChatConsent::Accepted`, a focused `'d'` keypress is
/// ordinary chat text (see `handle_key`'s `ChatConsent::Accepted` arm), so
/// re-detect must be reachable another way. `/detect` is the affordance —
/// it runs through the same `request_detect`/`set_detect_result` edge as
/// the pre-accept `'d'` key, and since the offer prompt isn't drawn once
/// accepted (`ui::tabs::chat::draw` only renders the gate pre-accept), the
/// result is echoed into the transcript instead.
#[test]
fn slash_detect_probes_and_echoes_result_while_accepted() {
let mut s = AppState::new("t".into(), "default-dark".into());
s.set_chat_config(
Some(crate::llm::LlmConfig {
base_url: "http://localhost:8000/v1".into(),
model: "m".into(),
api_key: None,
auth_header: None,
}),
true,
);
assert_eq!(s.chat_consent, ChatConsent::Accepted);

assert_eq!(s.handle_slash_command("/detect"), SlashOutcome::Handled);
assert!(s.chat_detecting && s.chat_detect_dispatch);
assert!(
s.chat.last().is_some(),
"probing while accepted is echoed into the transcript"
);

let local = crate::llm::detected_llm_config("http://localhost:13305/v1", "Llama-3.2-3B");
s.set_detect_result(Some(local.clone()));
assert_eq!(s.chat_detect_offer.as_ref(), Some(&local));
// No gate UI once accepted (draw_consent only renders pre-accept) — the
// offer must be surfaced in the transcript instead.
let last = s.chat.last().expect("echoed offer turn");
assert!(last.content.contains("/detect accept"));
}

/// `/detect accept` mid-session must integrate with the same
/// `chat_endpoint_rebuild` edge the initial pre-accept offer uses, or the
/// live agent silently keeps talking to the stale endpoint.
#[test]
fn slash_detect_accept_raises_endpoint_rebuild_like_initial_accept() {
let mut s = AppState::new("t".into(), "default-dark".into());
s.set_chat_config(
Some(crate::llm::LlmConfig {
base_url: "http://localhost:8000/v1".into(),
model: "m".into(),
api_key: None,
auth_header: None,
}),
true,
);
s.active_provider = ChatProvider::Openai; // observe the realignment to Local
let local = crate::llm::detected_llm_config("http://localhost:13305/v1", "Llama-3.2-3B");
s.set_detect_result(Some(local.clone()));

assert_eq!(
s.handle_slash_command("/detect accept"),
SlashOutcome::Handled
);
assert_eq!(s.chat_llm.as_ref(), Some(&local));
assert_eq!(s.active_provider, ChatProvider::Local);
assert_eq!(
s.chat_endpoint_rebuild,
Some(ChatProvider::Openai),
"re-detect accept raises the rebuild edge exactly like the initial accept"
);
}

#[test]
fn slash_detect_dismiss_and_unknown_subcommand() {
let mut s = AppState::new("t".into(), "default-dark".into());
s.set_detect_result(Some(crate::llm::detected_llm_config(
"http://localhost:8000/v1",
"m",
)));
assert_eq!(
s.handle_slash_command("/detect dismiss"),
SlashOutcome::Handled
);
assert!(s.chat_detect_offer.is_none());

assert_eq!(
s.handle_slash_command("/detect bogus"),
SlashOutcome::Handled
);
let last = s.chat.last().expect("error turn");
assert!(last.content.contains("unknown /detect action"));
}

/// Sub-commands are case-insensitive, matching `/permissions` / `/provider`.
#[test]
fn slash_detect_subcommand_is_case_insensitive() {
let mut s = AppState::new("t".into(), "default-dark".into());
s.set_detect_result(Some(crate::llm::detected_llm_config(
"http://localhost:8000/v1",
"m",
)));
// `DISMISS` (upper) must act exactly like `dismiss`.
assert_eq!(
s.handle_slash_command("/detect DISMISS"),
SlashOutcome::Handled
);
assert!(
s.chat_detect_offer.is_none(),
"uppercase sub-command is normalized and handled"
);
}

/// `/detect accept` / `/detect save` with nothing pending emits a hint
/// turn rather than a silent no-op.
#[test]
fn slash_detect_accept_without_offer_emits_hint() {
let mut s = AppState::new("t".into(), "default-dark".into());
assert!(s.chat_detect_offer.is_none());
assert_eq!(
s.handle_slash_command("/detect accept"),
SlashOutcome::Handled
);
// No endpoint was adopted; a hint explains why.
assert_eq!(s.chat_endpoint_rebuild, None);
let last = s.chat.last().expect("hint turn");
assert!(last.content.contains("no detected endpoint"));
}

#[test]
fn detect_key_available_on_gate_and_offer_keys_take_precedence() {
// `d` triggers detect from the Unavailable empty-state.
Expand Down
35 changes: 35 additions & 0 deletions crates/rocm-dash-tui/src/app/slash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,41 @@ impl AppState {
}
},
},
// `/detect [accept|save|dismiss]` (EAI-7354): re-run local-engine
// detection mid-session without colliding with focused text entry
// (a bare `d` keypress while chat is focused is ordinary chat text,
// and the pre-accept `'d'` gate key only exists before
// `ChatConsent::Accepted`). A bare `/detect` raises the probe; the
// result is echoed into the transcript (see `set_detect_result`)
// with these same sub-commands to act on it. `accept`/`save` route
// through the same reducer methods the pre-accept offer buttons
// use, so a re-detect+accept mid-session raises the
// `chat_endpoint_rebuild` edge exactly like the initial accept.
"detect" => {
// Lowercase the sub-command to match the case-insensitive
// parsing of sibling commands (`/permissions`, `/provider`).
let sub = rest.split_whitespace().nth(1).map(str::to_lowercase);
match sub.as_deref() {
None => self.request_detect(),
// `accept`/`save` on nothing pending would be a silent
// no-op in the reducer; emit a short hint instead.
Some("accept" | "save") if self.chat_detect_offer.is_none() => {
self.chat.push(ChatTurn::system(
"no detected endpoint to act on yet — run `/detect` first to probe \
for a local engine"
.to_string(),
));
}
Some("accept") => self.accept_detect_offer(),
Some("save") => self.save_detect_offer(),
Some("dismiss") => self.dismiss_detect_offer(),
Some(other) => {
self.chat.push(ChatTurn::error(format!(
"unknown /detect action `{other}` (try accept, save, dismiss, or bare /detect to probe)"
)));
}
}
}
// `/chat [prompt]`: with a prompt, send it to the agent (passthrough,
// exactly as a plain line would); bare `/chat` focuses the Chat tab.
"chat" => {
Expand Down
Loading