feat(agent): menu de profils restreint Claude/Codex + retrait custom (D7) — §17
Dernier lot de §17 : seuls les profils pilotables en mode structuré sont proposés à la sélection/création. - domain : AgentProfile::is_selectable() = structured_adapter.is_some(), source unique de vérité du prédicat. - infrastructure : AgentSessionFactory::supports délègue à is_selectable (supports et is_selectable ne peuvent plus diverger). - application : selectable_reference_profiles() = reference_profiles() filtré ; ReferenceProfiles et FirstRunState exposent la liste filtrée (Claude/Codex). reference_profiles() brut reste à 4 (data intacte) ⇒ un agent Gemini/Aider/custom legacy déjà configuré continue de tourner. - frontend : bloc AddCustomProfile retiré du wizard first-run, action addCustom retirée du viewmodel ; wizard n'affiche que la liste filtrée. Tests (QA) : is_selectable (table de vérité), cohérence stricte is_selectable<->supports, liste exposée=2 / data brute=4, garde anti-régression Vitest sur l'absence du bloc custom — validées par mutation. cargo test --workspace : 820 passed. npx vitest run : 344 passed. §17 COMPLET (D0->D7). Suivi restant : D6b (surfacer reply dans le writer wire .response.json pour la délégation par protocole fichier). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -569,8 +569,13 @@ pub async fn first_run_state(state: State<'_, AppState>) -> Result<FirstRunState
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `reference_profiles` — the pre-filled, editable reference catalogue
|
||||
/// (Claude/Codex/Gemini/Aider).
|
||||
/// `reference_profiles` — the pre-filled, editable reference catalogue offered to
|
||||
/// agent creation/selection.
|
||||
///
|
||||
/// Restricted to the **selectable** profiles (§17.3, lot D7): only profiles
|
||||
/// drivable in structured mode (today Claude + Codex) are returned. Gemini/Aider
|
||||
/// stay in the catalogue data but are not proposed, and there is no custom-profile
|
||||
/// entry. Persistence/editing of pre-existing (legacy) profiles is unaffected.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory).
|
||||
@ -651,8 +656,15 @@ pub async fn delete_profile(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `configure_profiles` — persist the batch of chosen/edited/custom profiles,
|
||||
/// closing the first run.
|
||||
/// `configure_profiles` — persist the batch of chosen/edited profiles, closing
|
||||
/// the first run.
|
||||
///
|
||||
/// The selection surface offered upstream is already restricted to selectable
|
||||
/// profiles (§17.3, D7), so the wizard sends only structured-drivable profiles
|
||||
/// and no arbitrary custom command. This command itself stays permissive on
|
||||
/// purpose: it must keep persisting any profile shape so a project with a
|
||||
/// pre-existing Gemini/Aider/custom **legacy** profile remains editable and
|
||||
/// runnable (we restrict creation, not the existing).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
|
||||
|
||||
@ -97,3 +97,25 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("aider reference profile is valid"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the **selectable** subset of [`reference_profiles`] — the profiles the
|
||||
/// first-run wizard and the agent-creation menu are allowed to offer (§17.3,
|
||||
/// lot D7).
|
||||
///
|
||||
/// A profile is selectable iff it can be driven in **structured** mode
|
||||
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
|
||||
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data
|
||||
/// catalogue is untouched) but are **not** proposed for selection. There is no
|
||||
/// custom-profile entry here either: the selection path offers only profiles we
|
||||
/// know how to pilot.
|
||||
///
|
||||
/// This filter is the single selection gate; `is_selectable` is the same
|
||||
/// predicate the `AgentSessionFactory` uses to decide it `supports` a profile, so
|
||||
/// the menu and the runtime can never disagree.
|
||||
#[must_use]
|
||||
pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.filter(AgentProfile::is_selectable)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
|
||||
@ -18,7 +18,7 @@ use domain::profile::AgentProfile;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::reference_profiles;
|
||||
use super::catalogue::selectable_reference_profiles;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
@ -256,11 +256,15 @@ impl ConfigureProfiles {
|
||||
/// Output of [`ReferenceProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReferenceProfilesOutput {
|
||||
/// The pre-filled, editable reference catalogue.
|
||||
/// The pre-filled, editable reference catalogue, **restricted to the
|
||||
/// selectable profiles** (§17.3, D7): only profiles drivable in structured
|
||||
/// mode are offered to selection/creation. Today: Claude + Codex.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
|
||||
/// Exposes the **selectable** reference catalogue for the agent-creation menu
|
||||
/// (§17.3, D7): the structured-drivable profiles only (Claude/Codex). Gemini and
|
||||
/// Aider remain in the raw catalogue data but are not proposed here.
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceProfiles;
|
||||
|
||||
@ -278,7 +282,7 @@ impl ReferenceProfiles {
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||
Ok(ReferenceProfilesOutput {
|
||||
profiles: reference_profiles(),
|
||||
profiles: selectable_reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -292,7 +296,9 @@ impl ReferenceProfiles {
|
||||
pub struct FirstRunStateOutput {
|
||||
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||
pub is_first_run: bool,
|
||||
/// The pre-filled reference catalogue to seed the wizard.
|
||||
/// The pre-filled reference catalogue to seed the wizard, **restricted to the
|
||||
/// selectable profiles** (§17.3, D7): only structured-drivable profiles
|
||||
/// (Claude/Codex) are offered. No custom-profile entry.
|
||||
pub reference_profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
@ -317,7 +323,7 @@ impl FirstRunState {
|
||||
let configured = self.store.is_configured().await?;
|
||||
Ok(FirstRunStateOutput {
|
||||
is_first_run: !configured,
|
||||
reference_profiles: reference_profiles(),
|
||||
reference_profiles: selectable_reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,8 +27,8 @@ pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, ChangeAgentProfile, ChangeAgentProfileInput,
|
||||
ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile,
|
||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
|
||||
@ -216,7 +216,20 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
|
||||
let out = uc.execute().await.unwrap();
|
||||
assert!(out.is_first_run);
|
||||
assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded");
|
||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
||||
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue.
|
||||
assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded");
|
||||
let commands: Vec<&str> = out
|
||||
.reference_profiles
|
||||
.iter()
|
||||
.map(|p| p.command.as_str())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered");
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
out.reference_profiles.iter().all(AgentProfile::is_selectable),
|
||||
"seeded profiles must all be selectable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -274,9 +287,53 @@ async fn delete_unknown_is_not_found_error() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn reference_profiles_use_case_returns_four() {
|
||||
async fn reference_profiles_use_case_returns_only_selectable() {
|
||||
// §17.3/D7: the selection use case exposes only structured-drivable profiles
|
||||
// (Claude + Codex). Gemini/Aider stay in the raw catalogue (see
|
||||
// `catalogue_*` tests below) but are not offered to selection/creation.
|
||||
let out = ReferenceProfiles::new().execute().await.unwrap();
|
||||
assert_eq!(out.profiles.len(), 4);
|
||||
assert_eq!(out.profiles.len(), 2);
|
||||
let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"]);
|
||||
assert!(out.profiles.iter().all(AgentProfile::is_selectable));
|
||||
}
|
||||
|
||||
/// §17.3/D7 — non-regression: the *raw* catalogue still carries the four profiles
|
||||
/// (data intact). Only the selection-facing use case is filtered.
|
||||
#[test]
|
||||
fn raw_catalogue_still_has_all_four_profiles() {
|
||||
let commands: Vec<String> = reference_profiles()
|
||||
.iter()
|
||||
.map(|p| p.command.clone())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex", "gemini", "aider"]);
|
||||
}
|
||||
|
||||
/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the two
|
||||
/// structured-drivable profiles, false for the two PTY-only ones. This assertion
|
||||
/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex
|
||||
/// lost theirs — i.e. it actually constrains behaviour.
|
||||
#[test]
|
||||
fn is_selectable_is_true_only_for_claude_and_codex() {
|
||||
let profiles = reference_profiles();
|
||||
let by_command: HashMap<&str, &AgentProfile> =
|
||||
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
|
||||
assert!(
|
||||
by_command["claude"].is_selectable(),
|
||||
"Claude carries a structured adapter ⇒ selectable"
|
||||
);
|
||||
assert!(
|
||||
by_command["codex"].is_selectable(),
|
||||
"Codex carries a structured adapter ⇒ selectable"
|
||||
);
|
||||
assert!(
|
||||
!by_command["gemini"].is_selectable(),
|
||||
"Gemini has no adapter ⇒ not selectable"
|
||||
);
|
||||
assert!(
|
||||
!by_command["aider"].is_selectable(),
|
||||
"Aider has no adapter ⇒ not selectable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -318,4 +318,19 @@ impl AgentProfile {
|
||||
self.structured_adapter = Some(adapter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
|
||||
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
|
||||
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
|
||||
/// est pilotable en mode structuré. C'est exactement le prédicat que
|
||||
/// l'`AgentSessionFactory` utilise pour décider qu'il sait piloter le profil
|
||||
/// (`supports`) : les deux doivent rester d'accord.
|
||||
///
|
||||
/// Ceci ne restreint **que** ce qui est offert à la création : un profil
|
||||
/// sans adapter reste un profil PTY/legacy valide, persistable et exécutable
|
||||
/// (zéro régression sur l'existant).
|
||||
#[must_use]
|
||||
pub fn is_selectable(&self) -> bool {
|
||||
self.structured_adapter.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,10 @@ fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
|
||||
#[async_trait]
|
||||
impl AgentSessionFactory for StructuredSessionFactory {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
profile.structured_adapter.is_some()
|
||||
// Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en
|
||||
// structuré. `is_selectable` et `supports` partagent ainsi le **même**
|
||||
// prédicat de domaine, ils ne peuvent pas diverger.
|
||||
profile.is_selectable()
|
||||
}
|
||||
|
||||
async fn start(
|
||||
|
||||
@ -352,6 +352,43 @@ mod tests {
|
||||
assert!(!factory.supports(&tui));
|
||||
}
|
||||
|
||||
/// §17.3/D7 — **strict coherence**: `AgentProfile::is_selectable` (the menu's
|
||||
/// selection gate) and `StructuredSessionFactory::supports` (the runtime's
|
||||
/// routing gate) must agree on **every** reference profile. If they ever
|
||||
/// diverged, the wizard could offer a profile the runtime cannot drive (or
|
||||
/// hide one it can). Asserting profile-by-profile over the real catalogue —
|
||||
/// including the expected `claude=codex=true`, `gemini=aider=false` truth
|
||||
/// table — means this fails the moment either gate changes without the other.
|
||||
#[tokio::test]
|
||||
async fn supports_and_is_selectable_agree_on_every_reference_profile() {
|
||||
use std::collections::HashMap;
|
||||
let factory = StructuredSessionFactory::new();
|
||||
let profiles = application::reference_profiles();
|
||||
|
||||
let mut expected: HashMap<&str, bool> = HashMap::new();
|
||||
expected.insert("claude", true);
|
||||
expected.insert("codex", true);
|
||||
expected.insert("gemini", false);
|
||||
expected.insert("aider", false);
|
||||
|
||||
assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles");
|
||||
for profile in &profiles {
|
||||
let selectable = profile.is_selectable();
|
||||
assert_eq!(
|
||||
selectable,
|
||||
factory.supports(profile),
|
||||
"is_selectable and supports must agree for `{}`",
|
||||
profile.command
|
||||
);
|
||||
assert_eq!(
|
||||
Some(&selectable),
|
||||
expected.get(profile.command.as_str()),
|
||||
"unexpected selectability for `{}`",
|
||||
profile.command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factory_routes_claude_and_codex() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
|
||||
Reference in New Issue
Block a user