ai-egui: drop a test's answer once the address has moved on (#33)

Only the Test button is disabled while a test runs; the address field
stays editable. Edit it mid-test and the answer still lands, so the panel
reported "Connected. Models found: N" for an address that was never
tested and carried those models onto the saved connection.

The form already treats an edit as invalidating: it clears the models and
the result line, saying an earlier test no longer describes what is
typed. The answer simply arrived afterwards and put them back. The panel
now remembers the address each test was started against and ignores an
answer that no longer matches, leaving the form as untested.

The runner's generation counter does not cover this: it discards answers
from superseded tests, and editing the address starts no new test.
This commit is contained in:
skylar taylor-barrick
2026-10-17 02:59:27 -06:00
committed by GitHub
parent aea912bb55
commit 8081df7f1d
2 changed files with 99 additions and 21 deletions
+29 -21
View File
@@ -39,7 +39,9 @@ pub struct ConnectionPanel {
manager: ConnectionManager,
runner: TestRunner,
form: ConnectionFormState,
testing: bool,
/// The address the running test was started against, so an answer arriving after the address
/// changed can be told from one that still describes it. `None` while no test is running.
testing: Option<String>,
}
impl ConnectionPanel {
@@ -53,42 +55,48 @@ impl ConnectionPanel {
runner: TestRunner::new(manager.clone()),
manager,
form,
testing: false,
testing: None,
}
}
/// Draws the panel and handles what was pressed.
pub fn show(&mut self, ui: &mut Ui) -> Option<PanelEvent> {
if self.testing
if self.testing.is_some()
&& let Some(answer) = self.runner.poll()
&& let Some(tested) = self.testing.take()
{
self.testing = false;
match answer {
Ok(report) => {
self.form.test = TestLine::Passed {
models: report.models.len(),
millis: u64::try_from(report.elapsed.as_millis()).unwrap_or(u64::MAX),
};
self.form.draft.set_models(report.models);
// The address stays editable while a test runs, so this answer may describe an
// address the form no longer holds. Editing already cleared the models and the
// result line, leaving the form as untested; taking the answer now would put one
// endpoint's models under another, so it is dropped instead.
if tested.trim() == self.form.draft.endpoint.trim() {
match answer {
Ok(report) => {
self.form.test = TestLine::Passed {
models: report.models.len(),
millis: u64::try_from(report.elapsed.as_millis()).unwrap_or(u64::MAX),
};
self.form.draft.set_models(report.models);
}
Err(e)
if e.kind == ErrorKind::Unsupported
&& self.form.draft.provider == "vertex-express" =>
{
self.form.test = TestLine::Unverified(e.message);
}
Err(e) => self.form.test = TestLine::Failed(e.message),
}
Err(e)
if e.kind == ErrorKind::Unsupported
&& self.form.draft.provider == "vertex-express" =>
{
self.form.test = TestLine::Unverified(e.message);
}
Err(e) => self.form.test = TestLine::Failed(e.message),
}
}
if self.testing {
if self.testing.is_some() {
ui.ctx().request_repaint_after(Duration::from_millis(100));
}
match connection_form(ui, &mut self.form, self.testing)? {
match connection_form(ui, &mut self.form, self.testing.is_some())? {
FormEvent::Cancel => Some(PanelEvent::Closed),
FormEvent::Test => {
self.form.test = TestLine::Running;
self.form.error = None;
self.testing = true;
self.testing = Some(self.form.draft.endpoint.trim().to_owned());
self.runner
.start(TestTarget::Draft(self.form.draft.clone()));
None
+70
View File
@@ -222,3 +222,73 @@ fn models_found_at_one_address_are_not_saved_for_another() {
);
assert!(saved.connections[0].models.is_empty());
}
/// Replaces what is in a text box while a test is running: the panel asks for a repaint every
/// frame then, so the `run()` in `replace_text` would never settle.
fn replace_text_mid_test(h: &mut Harness<'_, State>, nth: usize, text: &str) {
h.get_all_by_role(Role::TextInput)
.nth(nth)
.expect("field")
.focus();
h.step();
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.step();
h.get_all_by_role(Role::TextInput)
.nth(nth)
.expect("field")
.type_text(text);
h.step();
}
/// Answers slowly, so the address can be edited while the answer is still on its way.
fn slow_models_server() -> Server {
Server::start(|_| {
std::thread::sleep(Duration::from_millis(600));
Reply::Json(
200,
r#"{"object":"list","data":[{"id":"gpt-4o"},{"id":"gpt-image-1"}]}"#.into(),
)
})
}
#[test]
fn models_from_a_test_that_was_still_running_are_not_saved_for_the_new_address() {
// The sibling test above edits the address *after* the answer is in. This is the other
// order: the address is edited while the test is still running, so the answer arrives
// describing an address the form no longer holds.
let server = slow_models_server();
let mut r = rig();
r.h.run();
r.h.get_by_role(Role::ComboBox).click();
r.h.run();
r.h.get_by_label("Ollama").click();
r.h.run();
replace_text(&mut r.h, 1, &server.url);
r.h.get_by_label("Test").click();
r.h.step();
replace_text_mid_test(&mut r.h, 1, "http://127.0.0.1:9/v1");
// Let the answer land and be handled.
let end = Instant::now() + Duration::from_secs(5);
while Instant::now() < end {
r.h.step();
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(server.count(), 1, "the test did reach the server");
assert!(
r.h.query_by_label_contains("Connected. Models found")
.is_none(),
"an answer for an address the form no longer holds is not reported as this one's"
);
r.h.get_by_label("Save").click();
r.h.run();
assert!(matches!(r.h.state().event, Some(PanelEvent::Saved(_))));
let saved = r.shared.load().unwrap();
assert_eq!(
saved.connections[0].endpoint.as_deref(),
Some("http://127.0.0.1:9/v1")
);
assert!(
saved.connections[0].models.is_empty(),
"those models were found at the address that was replaced"
);
}