Merge feature/ticket28-firstrun-detect-hang into develop (#28)
First-run wizard figé : boutons « Save and continue » et « Detect installed CLIs » grisés à vie. Cause racine à deux étages, corrigée aux deux : - backend : `AgentRuntime::detect` était un `fn` synchrone pilotant un spawner async via un `block_on` imbriqué — panique sous le runtime Tauri, commande IPC sans réponse. Le port passe en `async fn`, la sonde est bornée par un timeout, et les profils OpenAI-compatible sont sondés en HTTP. - frontend : `busy` awaitait `detectProfiles`, si bien qu'une promesse pendante grisait les actions sans issue. La détection devient best-effort, suivie par un drapeau `detecting` séparé qui n'inhibe rien. QA vert (exécution réelle) : domain 241, application 81, infrastructure 269, app-tauri 63+15 ; vitest 569 tests / 59 fichiers ; tsc --noEmit exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3992,8 +3992,9 @@ mod mcp_serve_peer_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
@ -5374,8 +5375,9 @@ mod mcp_e2e_loopback_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -72,14 +72,11 @@ impl DetectProfiles {
|
|||||||
&self,
|
&self,
|
||||||
input: DetectProfilesInput,
|
input: DetectProfilesInput,
|
||||||
) -> Result<DetectProfilesOutput, AppError> {
|
) -> Result<DetectProfilesOutput, AppError> {
|
||||||
let results = input
|
let mut results = Vec::with_capacity(input.candidates.len());
|
||||||
.candidates
|
for profile in input.candidates {
|
||||||
.into_iter()
|
let available = self.runtime.detect(&profile).await.unwrap_or(false);
|
||||||
.map(|profile| {
|
results.push(ProfileAvailability { profile, available });
|
||||||
let available = self.runtime.detect(&profile).unwrap_or(false);
|
}
|
||||||
ProfileAvailability { profile, available }
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(DetectProfilesOutput { results })
|
Ok(DetectProfilesOutput { results })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -318,8 +318,9 @@ impl FakeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -344,8 +344,9 @@ impl FakeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -262,8 +262,9 @@ impl SkillStore for RecordingSkills {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -86,9 +86,11 @@ struct StubRuntime {
|
|||||||
by_command: HashMap<String, DetectResult>,
|
by_command: HashMap<String, DetectResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct YieldingRuntime;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AgentRuntime for StubRuntime {
|
impl AgentRuntime for StubRuntime {
|
||||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
match self.by_command.get(&profile.command) {
|
match self.by_command.get(&profile.command) {
|
||||||
Some(DetectResult::Available) => Ok(true),
|
Some(DetectResult::Available) => Ok(true),
|
||||||
Some(DetectResult::Missing) | None => Ok(false),
|
Some(DetectResult::Missing) | None => Ok(false),
|
||||||
@ -107,6 +109,24 @@ impl AgentRuntime for StubRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentRuntime for YieldingRuntime {
|
||||||
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_invocation(
|
||||||
|
&self,
|
||||||
|
_profile: &AgentProfile,
|
||||||
|
_ctx: &PreparedContext,
|
||||||
|
_cwd: &ProjectPath,
|
||||||
|
_session: &SessionPlan,
|
||||||
|
) -> Result<SpawnSpec, RuntimeError> {
|
||||||
|
unreachable!("not used in these tests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||||
@ -167,6 +187,20 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() {
|
||||||
|
let detect = DetectProfiles::new(Arc::new(YieldingRuntime));
|
||||||
|
let out = detect
|
||||||
|
.execute(DetectProfilesInput {
|
||||||
|
candidates: vec![profile(1, "Claude", "claude")],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("async detection must complete inside a multi-thread Tokio runtime");
|
||||||
|
|
||||||
|
assert_eq!(out.results.len(), 1);
|
||||||
|
assert!(out.results[0].available);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ConfigureProfiles
|
// ConfigureProfiles
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -284,8 +284,9 @@ impl FakeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -745,13 +745,14 @@ pub struct GraphCommit {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
|
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
|
||||||
/// injection. CPU-bound and synchronous — kept plain `fn`.
|
/// injection.
|
||||||
|
#[async_trait]
|
||||||
pub trait AgentRuntime: Send + Sync {
|
pub trait AgentRuntime: Send + Sync {
|
||||||
/// Detects whether the profile's command is available.
|
/// Detects whether the profile's command is available.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`RuntimeError`] on detection failure.
|
/// [`RuntimeError`] on detection failure.
|
||||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
|
async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
|
||||||
|
|
||||||
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
|
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
|
||||||
/// the agent in `cwd` with the prepared context.
|
/// the agent in `cwd` with the prepared context.
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
//! `cwd` (template substitution), and the context-injection plan derived from
|
//! `cwd` (template substitution), and the context-injection plan derived from
|
||||||
//! the profile's [`ContextInjection`]. This is the testable core of L5.
|
//! the profile's [`ContextInjection`]. This is the testable core of L5.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
@ -25,9 +25,11 @@ use domain::ports::{
|
|||||||
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
|
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
|
||||||
SpawnSpec,
|
SpawnSpec,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
|
const DETECTION_TIMEOUT: Duration = Duration::from_millis(800);
|
||||||
|
|
||||||
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
|
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
|
||||||
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
|
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -179,14 +181,16 @@ impl CliAgentRuntime {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AgentRuntime for CliAgentRuntime {
|
impl AgentRuntime for CliAgentRuntime {
|
||||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
|
if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) {
|
||||||
|
return Ok(Self::detect_openai_compatible(profile).await);
|
||||||
|
}
|
||||||
|
|
||||||
let spec = Self::detection_spec(profile)?;
|
let spec = Self::detection_spec(profile)?;
|
||||||
// The port is synchronous but the spawner is async; block on it. The
|
|
||||||
// adapter is invoked off the Tauri async runtime (detection is a
|
|
||||||
// short-lived probe), so a transient runtime here is acceptable and keeps
|
|
||||||
// the `AgentRuntime` port plain-`fn` as the domain declares it.
|
|
||||||
let spawner = Arc::clone(&self.spawner);
|
let spawner = Arc::clone(&self.spawner);
|
||||||
let output = futures_block_on(async move { spawner.run(spec).await })
|
let output = tokio::time::timeout(DETECTION_TIMEOUT, spawner.run(spec))
|
||||||
|
.await
|
||||||
|
.map_err(|_| RuntimeError::Detection("detection timed out".to_owned()))?
|
||||||
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
||||||
Ok(output.status.code == Some(0))
|
Ok(output.status.code == Some(0))
|
||||||
}
|
}
|
||||||
@ -225,13 +229,33 @@ impl AgentRuntime for CliAgentRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal block-on helper that drives a future to completion on a fresh
|
impl CliAgentRuntime {
|
||||||
/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose
|
async fn detect_openai_compatible(profile: &AgentProfile) -> bool {
|
||||||
/// port signature is synchronous while the [`ProcessSpawner`] it calls is async.
|
let Some(chat) = &profile.chat_http else {
|
||||||
fn futures_block_on<F: std::future::Future>(fut: F) -> F::Output {
|
return false;
|
||||||
tokio::runtime::Builder::new_current_thread()
|
};
|
||||||
.enable_all()
|
let url = openai_compatible_models_endpoint(&chat.endpoint);
|
||||||
.build()
|
let probe = async move {
|
||||||
.expect("failed to build a current-thread runtime for detection")
|
let client = reqwest::Client::builder()
|
||||||
.block_on(fut)
|
.timeout(DETECTION_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
let response = client.get(url).send().await.ok()?;
|
||||||
|
Some(response.status().is_success())
|
||||||
|
};
|
||||||
|
tokio::time::timeout(DETECTION_TIMEOUT, probe)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_compatible_models_endpoint(endpoint: &str) -> String {
|
||||||
|
let trimmed = endpoint.trim_end_matches('/');
|
||||||
|
let base = trimmed
|
||||||
|
.strip_suffix("/chat/completions")
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.trim_end_matches('/');
|
||||||
|
format!("{base}/models")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ use domain::ports::{
|
|||||||
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
|
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
|
||||||
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
|
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
use domain::profile::{
|
||||||
|
AgentProfile, ContextInjection, HttpChatConfig, SessionStrategy, StructuredAdapter,
|
||||||
|
};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::MarkdownDoc;
|
use domain::MarkdownDoc;
|
||||||
use infrastructure::CliAgentRuntime;
|
use infrastructure::CliAgentRuntime;
|
||||||
@ -297,60 +299,85 @@ fn detection_spec_falls_back_to_command_version() {
|
|||||||
// detect (mocked spawner)
|
// detect (mocked spawner)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn detect_true_on_exit_zero() {
|
async fn detect_true_on_exit_zero() {
|
||||||
let rt = runtime_with(Ok(Output {
|
let rt = runtime_with(Ok(Output {
|
||||||
status: ExitStatus { code: Some(0) },
|
status: ExitStatus { code: Some(0) },
|
||||||
stdout: Vec::new(),
|
stdout: Vec::new(),
|
||||||
stderr: Vec::new(),
|
stderr: Vec::new(),
|
||||||
}));
|
}));
|
||||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||||
assert!(rt.detect(&p).unwrap());
|
assert!(rt.detect(&p).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn detect_false_on_nonzero_exit() {
|
async fn detect_false_on_nonzero_exit() {
|
||||||
let rt = runtime_with(Ok(Output {
|
let rt = runtime_with(Ok(Output {
|
||||||
status: ExitStatus { code: Some(127) },
|
status: ExitStatus { code: Some(127) },
|
||||||
stdout: Vec::new(),
|
stdout: Vec::new(),
|
||||||
stderr: Vec::new(),
|
stderr: Vec::new(),
|
||||||
}));
|
}));
|
||||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||||
assert!(!rt.detect(&p).unwrap());
|
assert!(!rt.detect(&p).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn detect_false_on_signal_terminated() {
|
async fn detect_false_on_signal_terminated() {
|
||||||
let rt = runtime_with(Ok(Output {
|
let rt = runtime_with(Ok(Output {
|
||||||
status: ExitStatus { code: None },
|
status: ExitStatus { code: None },
|
||||||
stdout: Vec::new(),
|
stdout: Vec::new(),
|
||||||
stderr: Vec::new(),
|
stderr: Vec::new(),
|
||||||
}));
|
}));
|
||||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||||
assert!(!rt.detect(&p).unwrap());
|
assert!(!rt.detect(&p).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn detect_propagates_spawner_error() {
|
async fn detect_propagates_spawner_error() {
|
||||||
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
|
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
|
||||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||||
let err = rt.detect(&p).expect_err("spawner error surfaces");
|
let err = rt.detect(&p).await.expect_err("spawner error surfaces");
|
||||||
assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
|
assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn detect_runs_the_detection_spec_command() {
|
async fn detect_runs_the_detection_spec_command() {
|
||||||
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
|
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
|
||||||
let rt = CliAgentRuntime::new(recorder.clone());
|
let rt = CliAgentRuntime::new(recorder.clone());
|
||||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||||
|
|
||||||
rt.detect(&p).unwrap();
|
rt.detect(&p).await.unwrap();
|
||||||
|
|
||||||
let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
|
let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
|
||||||
assert_eq!(spec.command, "mycli");
|
assert_eq!(spec.command, "mycli");
|
||||||
assert_eq!(spec.args, vec!["probe", "--json"]);
|
assert_eq!(spec.args, vec!["probe", "--json"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detect_openai_compatible_uses_http_endpoint_not_cli_spawner() {
|
||||||
|
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
|
||||||
|
let rt = CliAgentRuntime::new(recorder.clone());
|
||||||
|
let p = profile(ContextInjection::stdin(), "{projectRoot}")
|
||||||
|
.with_structured_adapter(StructuredAdapter::OpenAiCompatible)
|
||||||
|
.with_chat_http(
|
||||||
|
HttpChatConfig::new(
|
||||||
|
"http://127.0.0.1:1/v1",
|
||||||
|
"model",
|
||||||
|
None,
|
||||||
|
Some(1000),
|
||||||
|
Some(1000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!rt.detect(&p).await.unwrap());
|
||||||
|
assert!(
|
||||||
|
recorder.0.lock().unwrap().is_none(),
|
||||||
|
"OpenAI-compatible detection must not spawn the profile command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// prepare_invocation — session args (T3 truth table)
|
// prepare_invocation — session args (T3 truth table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -227,8 +227,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -226,8 +226,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FakeRuntime;
|
struct FakeRuntime;
|
||||||
|
#[async_trait]
|
||||||
impl AgentRuntime for FakeRuntime {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
fn prepare_invocation(
|
fn prepare_invocation(
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
@ -13,9 +14,11 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
|
||||||
import { MockProfileGateway } from "@/adapters/mock";
|
import { MockProfileGateway } from "@/adapters/mock";
|
||||||
|
import type { ProfileAvailability } from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { FirstRunWizard } from "./FirstRunWizard";
|
import { FirstRunWizard } from "./FirstRunWizard";
|
||||||
|
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
||||||
|
|
||||||
function renderWizard(
|
function renderWizard(
|
||||||
profile: MockProfileGateway = new MockProfileGateway(),
|
profile: MockProfileGateway = new MockProfileGateway(),
|
||||||
@ -301,6 +304,69 @@ describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ticket #28 — the wizard must survive a detection that never answers. When the
|
||||||
|
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
||||||
|
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
||||||
|
// `busy` must therefore never be tied to detection, or both buttons stay greyed
|
||||||
|
// out for good and the first run cannot be completed.
|
||||||
|
describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
|
||||||
|
/** A gateway whose `detectProfiles` promise stays pending forever. */
|
||||||
|
class HangingDetectGateway extends MockProfileGateway {
|
||||||
|
override detectProfiles(): Promise<ProfileAvailability[]> {
|
||||||
|
return new Promise<ProfileAvailability[]>(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps Save and Detect enabled once firstRunState answered", async () => {
|
||||||
|
renderWizard(new HangingDetectGateway());
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// The rows are rendered, so the blocking load is over: both actions are live.
|
||||||
|
const save = screen.getByRole("button", { name: "Save and continue" });
|
||||||
|
const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
|
||||||
|
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((detect as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
// Detection is merely reported as in flight, never as a blocking state.
|
||||||
|
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
||||||
|
|
||||||
|
// Re-probing by hand must not freeze them either.
|
||||||
|
fireEvent.click(detect);
|
||||||
|
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((detect as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still finishes the first run while detection hangs", async () => {
|
||||||
|
const profile = new HangingDetectGateway();
|
||||||
|
const { onDone } = renderWizard(profile, vi.fn());
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Nothing got pre-checked (detection never answered): tick a profile by hand.
|
||||||
|
fireEvent.click(screen.getByLabelText("use Claude Code"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
expect(saved.map((p) => p.command)).toEqual(["claude"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the detecting flag on timeout even if the promise never settles", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
renderWizard(new HangingDetectGateway());
|
||||||
|
await waitForLoaded();
|
||||||
|
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||||
/** A gateway whose first run is already done (profiles configured). */
|
/** A gateway whose first run is already done (profiles configured). */
|
||||||
async function configuredGateway() {
|
async function configuredGateway() {
|
||||||
|
|||||||
@ -78,6 +78,8 @@ export function FirstRunWizard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
|
{/* `detecting` deliberately does NOT disable this button (ticket #28):
|
||||||
|
detection is best-effort and may never answer. */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void vm.detect()}
|
onClick={() => void vm.detect()}
|
||||||
disabled={vm.busy}
|
disabled={vm.busy}
|
||||||
@ -85,6 +87,11 @@ export function FirstRunWizard({
|
|||||||
>
|
>
|
||||||
Detect installed CLIs
|
Detect installed CLIs
|
||||||
</Button>
|
</Button>
|
||||||
|
{vm.detecting && (
|
||||||
|
<span role="status" className="text-xs text-muted">
|
||||||
|
Detecting…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<ul className="flex list-none flex-col gap-3 p-0">
|
<ul className="flex list-none flex-col gap-3 p-0">
|
||||||
|
|||||||
@ -3,9 +3,17 @@
|
|||||||
* (L5). Owns the wizard state (selectable reference candidates + selection +
|
* (L5). Owns the wizard state (selectable reference candidates + selection +
|
||||||
* availability) and the actions. Consumes the {@link ProfileGateway}
|
* availability) and the actions. Consumes the {@link ProfileGateway}
|
||||||
* exclusively — never `invoke()` — so the feature is testable with a mock.
|
* exclusively — never `invoke()` — so the feature is testable with a mock.
|
||||||
|
*
|
||||||
|
* **Invariant (ticket #28)**: `busy` gates only the requests the wizard cannot
|
||||||
|
* render without (`firstRunState`) or that mutate state (`configureProfiles`).
|
||||||
|
* It NEVER depends on `detectProfiles`: CLI detection is a detached, best-effort
|
||||||
|
* step tracked by the separate {@link FirstRunViewModel.detecting} flag, bounded
|
||||||
|
* by {@link DETECT_TIMEOUT_MS}. A detection that panics backend-side (leaving the
|
||||||
|
* `invoke` promise forever pending) must not freeze "Save and continue" /
|
||||||
|
* "Detect installed CLIs".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
@ -15,6 +23,9 @@ import type {
|
|||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** How long the UI waits for a detection round before giving up on it. */
|
||||||
|
export const DETECT_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
/** One row in the wizard: a candidate profile, selected state, availability. */
|
/** One row in the wizard: a candidate profile, selected state, availability. */
|
||||||
export interface WizardEntry {
|
export interface WizardEntry {
|
||||||
profile: AgentProfile;
|
profile: AgentProfile;
|
||||||
@ -31,8 +42,10 @@ export interface FirstRunViewModel {
|
|||||||
entries: WizardEntry[];
|
entries: WizardEntry[];
|
||||||
/** Last error message, or null. */
|
/** Last error message, or null. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** Whether a request is in flight. */
|
/** Whether a blocking request is in flight (load / save). Never detection. */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
/** Whether a best-effort detection round is in flight. Never disables actions. */
|
||||||
|
detecting: boolean;
|
||||||
/** Toggles selection of a candidate by id. */
|
/** Toggles selection of a candidate by id. */
|
||||||
toggle: (id: string) => void;
|
toggle: (id: string) => void;
|
||||||
/** Replaces a candidate's profile (edited command/args/injection). */
|
/** Replaces a candidate's profile (edited command/args/injection). */
|
||||||
@ -60,41 +73,101 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
const [entries, setEntries] = useState<WizardEntry[]>([]);
|
const [entries, setEntries] = useState<WizardEntry[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
|
||||||
|
/** Monotonic id of the latest detection round; older rounds are ignored. */
|
||||||
|
const detectRun = useRef(0);
|
||||||
|
const detectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
// Unmount: invalidate any in-flight round (its promise may never settle).
|
||||||
|
detectRun.current += 1;
|
||||||
|
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort detection round, detached from `busy`. A round is abandoned
|
||||||
|
* after {@link DETECT_TIMEOUT_MS} — the underlying promise may stay pending
|
||||||
|
* forever (backend panic ⇒ no IPC response), so `detecting` is released by a
|
||||||
|
* timer, never by the promise alone. `preselect` ticks the installed CLIs (the
|
||||||
|
* auto-detection on open); the manual button only refreshes availability.
|
||||||
|
*/
|
||||||
|
const runDetection = useCallback(
|
||||||
|
async (
|
||||||
|
candidates: AgentProfile[],
|
||||||
|
opts: { preselect: boolean; reportError: boolean },
|
||||||
|
) => {
|
||||||
|
const run = detectRun.current + 1;
|
||||||
|
detectRun.current = run;
|
||||||
|
const stale = () => detectRun.current !== run;
|
||||||
|
|
||||||
|
setDetecting(true);
|
||||||
|
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
|
||||||
|
detectTimer.current = setTimeout(() => {
|
||||||
|
if (!stale()) setDetecting(false);
|
||||||
|
}, DETECT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results: ProfileAvailability[] =
|
||||||
|
await profile.detectProfiles(candidates);
|
||||||
|
if (stale()) return;
|
||||||
|
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => {
|
||||||
|
const available = byId.get(e.profile.id) ?? e.available;
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
available,
|
||||||
|
selected: opts.preselect ? available === true : e.selected,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Detection unavailable: rows stay as they are (the user can tick them
|
||||||
|
// by hand and re-run). Only the explicit button surfaces the failure.
|
||||||
|
if (stale()) return;
|
||||||
|
if (opts.reportError) setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
if (!stale()) {
|
||||||
|
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
|
||||||
|
detectTimer.current = null;
|
||||||
|
setDetecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[profile],
|
||||||
|
);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
let refs: AgentProfile[] | null = null;
|
||||||
try {
|
try {
|
||||||
const state: FirstRunState = await profile.firstRunState();
|
const state: FirstRunState = await profile.firstRunState();
|
||||||
setIsFirstRun(state.isFirstRun);
|
setIsFirstRun(state.isFirstRun);
|
||||||
const refs = state.referenceProfiles;
|
refs = state.referenceProfiles;
|
||||||
// Show the rows immediately (nothing pre-checked yet).
|
// Show the rows immediately (nothing pre-checked yet).
|
||||||
setEntries(
|
setEntries(
|
||||||
refs.map((p) => ({ profile: p, selected: false, available: null })),
|
refs.map((p) => ({ profile: p, selected: false, available: null })),
|
||||||
);
|
);
|
||||||
// Auto-detect once on open: only the CLIs actually installed end up
|
|
||||||
// pre-checked. The manual "Detect installed CLIs" button stays available to
|
|
||||||
// re-probe. If detection is unavailable, rows stay unchecked (the user can
|
|
||||||
// still tick them by hand and re-run detection).
|
|
||||||
try {
|
|
||||||
const results = await profile.detectProfiles(refs);
|
|
||||||
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
|
|
||||||
setEntries(
|
|
||||||
refs.map((p) => {
|
|
||||||
const available = byId.get(p.id) ?? null;
|
|
||||||
return { profile: p, selected: available === true, available };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* detection unavailable: leave rows unchecked */
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
setIsFirstRun(false);
|
setIsFirstRun(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Released as soon as the rows are rendered — detection is not awaited.
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [profile]);
|
|
||||||
|
// Auto-detect once on open, detached: only the CLIs actually installed end
|
||||||
|
// up pre-checked. The manual "Detect installed CLIs" button stays available
|
||||||
|
// to re-probe, whatever happens to this round.
|
||||||
|
if (refs !== null) {
|
||||||
|
void runDetection(refs, { preselect: true, reportError: false });
|
||||||
|
}
|
||||||
|
}, [profile, runDetection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
@ -119,25 +192,12 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const detect = useCallback(async () => {
|
const detect = useCallback(async () => {
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
await runDetection(
|
||||||
const candidates = entries.map((e) => e.profile);
|
entries.map((e) => e.profile),
|
||||||
const results: ProfileAvailability[] =
|
{ preselect: false, reportError: true },
|
||||||
await profile.detectProfiles(candidates);
|
);
|
||||||
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
|
}, [runDetection, entries]);
|
||||||
setEntries((prev) =>
|
|
||||||
prev.map((e) => ({
|
|
||||||
...e,
|
|
||||||
available: byId.get(e.profile.id) ?? e.available,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
setError(describe(e));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}, [profile, entries]);
|
|
||||||
|
|
||||||
const finish = useCallback(async () => {
|
const finish = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -158,6 +218,7 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
entries,
|
entries,
|
||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
|
detecting,
|
||||||
toggle,
|
toggle,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
remove,
|
remove,
|
||||||
|
|||||||
Reference in New Issue
Block a user