feat(persistence): P8b — écriture providers.json (resumable par provider)
Au lancement structuré, le resumable exposé par le moteur est rangé dans providers.json sous (id de paire, provider) — best-effort, n'altère jamais le lancement. - domaine : StructuredAdapter::provider_key() ⇒ "claude"/"codex" (clé stable par famille, lisible, indépendante de l'uuid d'instance) - application : port ProviderSessionProvider (root par appel) + with_provider_session_provider ; helper persist_provider_session dans launch_structured (skip si provider/adapter/id moteur absents ou set KO) - app-tauri : AppProviderSessionProvider (FsProviderSessionStore sur le root) - tests : 6 cas (nominal claude/codex, no-op sans provider/sans id moteur, best-effort, mapping provider_key) ; domain+application verts Lecture du store pour --resume = P8c. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -42,7 +42,7 @@ use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
|
|||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
|
||||||
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsHandoffStore, FsMemoryStore, HeuristicHandoffSummarizer,
|
FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||||
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
||||||
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
|
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
|
||||||
@ -101,6 +101,25 @@ impl application::HandoffProvider for AppHandoffProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
|
||||||
|
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
|
||||||
|
/// lancement en cours.
|
||||||
|
///
|
||||||
|
/// Jumeau stateless de [`AppHandoffProvider`] : [`LaunchAgent`] est unique pour tous
|
||||||
|
/// les projets, alors que `providers.json` est **par project root**
|
||||||
|
/// (`<root>/.ideai/conversations/providers.json`). On construit donc un store frais
|
||||||
|
/// par lancement, ciblant le bon dossier. Sans état (zéro champ), partagé via `Arc`.
|
||||||
|
struct AppProviderSessionProvider;
|
||||||
|
|
||||||
|
impl application::ProviderSessionProvider for AppProviderSessionProvider {
|
||||||
|
fn provider_session_store_for(
|
||||||
|
&self,
|
||||||
|
root: &domain::project::ProjectPath,
|
||||||
|
) -> Option<Arc<dyn domain::ProviderSessionStore>> {
|
||||||
|
Some(Arc::new(FsProviderSessionStore::new(root)) as Arc<dyn domain::ProviderSessionStore>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
/// Everything the IPC layer needs at runtime, managed by Tauri.
|
||||||
///
|
///
|
||||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||||
@ -627,7 +646,14 @@ impl AppState {
|
|||||||
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
|
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
|
||||||
// son résumé est réinjecté dans le convention file. Best-effort, additif :
|
// son résumé est réinjecté dans le convention file. Best-effort, additif :
|
||||||
// un handoff absent/illisible ⇒ lancement normal sans section.
|
// un handoff absent/illisible ⇒ lancement normal sans section.
|
||||||
.with_handoff_provider(Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>),
|
.with_handoff_provider(Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>)
|
||||||
|
// Resumable moteur par provider (lot P8b) : après un lancement structuré
|
||||||
|
// exposant un id de session moteur, rangé sous la clé de paire dans
|
||||||
|
// `<root>/.ideai/conversations/providers.json`. Best-effort, additif : une
|
||||||
|
// écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture.
|
||||||
|
.with_provider_session_provider(
|
||||||
|
Arc::new(AppProviderSessionProvider) as Arc<dyn application::ProviderSessionProvider>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||||
|
|||||||
@ -21,8 +21,8 @@ use domain::ports::{
|
|||||||
use domain::{
|
use domain::{
|
||||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||||
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
|
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
|
||||||
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId,
|
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore,
|
||||||
SessionKind, SessionStatus, Skill, TerminalSession,
|
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -61,6 +61,25 @@ pub trait HandoffProvider: Send + Sync {
|
|||||||
fn handoff_store_for(&self, root: &ProjectPath) -> Option<Arc<dyn HandoffStore>>;
|
fn handoff_store_for(&self, root: &ProjectPath) -> Option<Arc<dyn HandoffStore>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours
|
||||||
|
/// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance
|
||||||
|
/// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction),
|
||||||
|
/// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel).
|
||||||
|
///
|
||||||
|
/// Sert à ranger, après un lancement structuré, l'id de session **moteur**
|
||||||
|
/// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None`
|
||||||
|
/// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call
|
||||||
|
/// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
|
||||||
|
pub trait ProviderSessionProvider: Send + Sync {
|
||||||
|
/// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé
|
||||||
|
/// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement
|
||||||
|
/// l'écriture du resumable.
|
||||||
|
fn provider_session_store_for(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Option<Arc<dyn ProviderSessionStore>>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CreateAgentFromScratch
|
// CreateAgentFromScratch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -805,6 +824,13 @@ pub struct LaunchAgent {
|
|||||||
/// reprise (zéro régression pour les call sites/tests legacy). Un handoff
|
/// reprise (zéro régression pour les call sites/tests legacy). Un handoff
|
||||||
/// absent/illisible ⇒ lancement normal, jamais d'échec.
|
/// absent/illisible ⇒ lancement normal, jamais d'échec.
|
||||||
handoffs: Option<Arc<dyn HandoffProvider>>,
|
handoffs: Option<Arc<dyn HandoffProvider>>,
|
||||||
|
/// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger
|
||||||
|
/// **best-effort** l'id de session moteur (resumable du provider) sous la clé de
|
||||||
|
/// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage
|
||||||
|
/// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro
|
||||||
|
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
|
||||||
|
/// normal, jamais d'échec.
|
||||||
|
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LaunchAgent {
|
impl LaunchAgent {
|
||||||
@ -839,9 +865,27 @@ impl LaunchAgent {
|
|||||||
session_factory: None,
|
session_factory: None,
|
||||||
structured: None,
|
structured: None,
|
||||||
handoffs: None,
|
handoffs: None,
|
||||||
|
provider_sessions: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
|
||||||
|
/// après un lancement structuré exposant un id de session moteur, range
|
||||||
|
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
|
||||||
|
/// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture —
|
||||||
|
/// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
|
||||||
|
///
|
||||||
|
/// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
|
||||||
|
/// `LaunchAgent::new(...).with_provider_session_provider(provider)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_provider_session_provider(
|
||||||
|
mut self,
|
||||||
|
provider_sessions: Arc<dyn ProviderSessionProvider>,
|
||||||
|
) -> Self {
|
||||||
|
self.provider_sessions = Some(provider_sessions);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au
|
/// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au
|
||||||
/// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`]
|
/// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`]
|
||||||
/// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section
|
/// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section
|
||||||
@ -1224,6 +1268,7 @@ impl LaunchAgent {
|
|||||||
&run_dir,
|
&run_dir,
|
||||||
&session_plan,
|
&session_plan,
|
||||||
pair_conversation_id,
|
pair_conversation_id,
|
||||||
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
)
|
)
|
||||||
@ -1284,6 +1329,7 @@ impl LaunchAgent {
|
|||||||
run_dir: &ProjectPath,
|
run_dir: &ProjectPath,
|
||||||
session_plan: &SessionPlan,
|
session_plan: &SessionPlan,
|
||||||
pair_conversation_id: String,
|
pair_conversation_id: String,
|
||||||
|
root: &ProjectPath,
|
||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
) -> Result<LaunchAgentOutput, AppError> {
|
) -> Result<LaunchAgentOutput, AppError> {
|
||||||
@ -1309,6 +1355,20 @@ impl LaunchAgent {
|
|||||||
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
|
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
|
||||||
let engine_session_id = session.conversation_id();
|
let engine_session_id = session.conversation_id();
|
||||||
|
|
||||||
|
// ── P8b — range le resumable moteur par provider dans `providers.json`
|
||||||
|
// (best-effort, jamais bloquant) ──
|
||||||
|
// On persiste `(pair_conversation_id, provider_key) → engine_session_id`
|
||||||
|
// **uniquement** quand le moteur expose un id ET que le provider est câblé.
|
||||||
|
// La clé provider est dérivée de la **famille de moteur** (`structured_adapter`),
|
||||||
|
// pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la
|
||||||
|
// même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
|
||||||
|
// erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
|
||||||
|
// par cette persistance (lot P8c lira ce store pour `--resume`).
|
||||||
|
if let Some(engine_id) = engine_session_id.as_deref() {
|
||||||
|
self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
|
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
|
||||||
|
|
||||||
self.events.publish(DomainEvent::AgentLaunched {
|
self.events.publish(DomainEvent::AgentLaunched {
|
||||||
@ -1331,6 +1391,44 @@ impl LaunchAgent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé
|
||||||
|
/// `(pair_conversation_id, provider_key)` dans `providers.json` du projet.
|
||||||
|
///
|
||||||
|
/// `provider_key` est dérivée de la **famille de moteur** du profil
|
||||||
|
/// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne
|
||||||
|
/// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est
|
||||||
|
/// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent
|
||||||
|
/// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du
|
||||||
|
/// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a
|
||||||
|
/// déjà réussi à ce stade.
|
||||||
|
async fn persist_provider_session(
|
||||||
|
&self,
|
||||||
|
root: &ProjectPath,
|
||||||
|
profile: &AgentProfile,
|
||||||
|
pair_conversation_id: &str,
|
||||||
|
engine_id: &str,
|
||||||
|
) {
|
||||||
|
let Some(provider) = self.provider_sessions.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(adapter) = profile.structured_adapter else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Le `conversation_id` d'une cellule est un id de paire (UUID, §C3).
|
||||||
|
let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let conversation = ConversationId::from_uuid(uuid);
|
||||||
|
let Some(store) = provider.provider_session_store_for(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Best-effort : toute erreur du store est avalée — le lancement a déjà réussi,
|
||||||
|
// la persistance du resumable ne doit jamais le casser (lot P8c lira ce store).
|
||||||
|
let _ = store
|
||||||
|
.set(conversation, adapter.provider_key(), engine_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
||||||
/// strategy and the cell's current `conversation_id` (T4).
|
/// strategy and the cell's current `conversation_id` (T4).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -26,6 +26,7 @@ pub use resume::{
|
|||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||||
|
ProviderSessionProvider,
|
||||||
LaunchAgent,
|
LaunchAgent,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ pub use agent::{
|
|||||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||||
HandoffProvider,
|
HandoffProvider, ProviderSessionProvider,
|
||||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||||
|
|||||||
@ -538,6 +538,87 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::ProviderSessionProvider;
|
||||||
|
use domain::{ConversationId, ProviderSessionStore};
|
||||||
|
|
||||||
|
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
|
||||||
|
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
|
||||||
|
/// `set` can be made to fail (best-effort scenario) so we can prove a write error
|
||||||
|
/// never degrades the launch.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct FakeProviderSessionStore {
|
||||||
|
entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
|
||||||
|
fail_set: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProviderSessionStore {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
/// A store whose `set` always errors (best-effort proof).
|
||||||
|
fn failing() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
fail_set: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||||
|
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&(conversation, provider.to_owned()))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.entries.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderSessionStore for FakeProviderSessionStore {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<Option<String>, StoreError> {
|
||||||
|
Ok(self.get_sync(conversation, provider_id))
|
||||||
|
}
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
provider_id: &str,
|
||||||
|
resumable_id: &str,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
if self.fail_set {
|
||||||
|
return Err(StoreError::Io("forced set failure".to_owned()));
|
||||||
|
}
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert((conversation, provider_id.to_owned()), resumable_id.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`ProviderSessionProvider`] that hands back the same store for any root (the
|
||||||
|
/// launch tests use a single project root).
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FakeProviderSessionProvider(Arc<FakeProviderSessionStore>);
|
||||||
|
|
||||||
|
impl ProviderSessionProvider for FakeProviderSessionProvider {
|
||||||
|
fn provider_session_store_for(
|
||||||
|
&self,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
) -> Option<Arc<dyn ProviderSessionStore>> {
|
||||||
|
Some(Arc::clone(&self.0) as Arc<dyn ProviderSessionStore>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Builders
|
// Builders
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1170,3 +1251,204 @@ async fn structured_profile_fresh_cell_resolves_to_none() {
|
|||||||
"fresh structured cell without a session block plans None"
|
"fresh structured cell without a session block plans None"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// LOT P8b — écriture de `providers.json` au lancement structuré
|
||||||
|
//
|
||||||
|
// Prouve `persist_provider_session` (best-effort) : après un lancement structuré
|
||||||
|
// exposant un id de session moteur, IdeA range `(pair, provider_key) →
|
||||||
|
// engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de
|
||||||
|
// skip (provider absent, id moteur absent) et la robustesse (set en échec) sont
|
||||||
|
// couvertes — le lancement réussit toujours.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé
|
||||||
|
/// provider `"codex"`.
|
||||||
|
fn structured_codex_profile(id: ProfileId) -> AgentProfile {
|
||||||
|
AgentProfile::new(
|
||||||
|
id,
|
||||||
|
"Codex Structuré",
|
||||||
|
"codex",
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||||
|
Some("codex --version".to_owned()),
|
||||||
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_structured_adapter(StructuredAdapter::Codex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
|
||||||
|
/// over the given profile/factory, returning the launcher, the agent and the
|
||||||
|
/// observable provider store. When `provider` is `None`, no provider is wired
|
||||||
|
/// (legacy path).
|
||||||
|
fn launch_fixture_p8b(
|
||||||
|
profile: AgentProfile,
|
||||||
|
factory: FakeFactory,
|
||||||
|
store: Option<Arc<FakeProviderSessionStore>>,
|
||||||
|
) -> (Arc<LaunchAgent>, Agent) {
|
||||||
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||||
|
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||||
|
let profiles = FakeProfiles::new(vec![profile]);
|
||||||
|
let runtime = FakeRuntime::new();
|
||||||
|
let fs = FakeFs::default();
|
||||||
|
let pty = FakePty::new(sid(777));
|
||||||
|
let sessions = Arc::new(TerminalSessions::new());
|
||||||
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
|
let bus = SpyBus::default();
|
||||||
|
let mut launch = LaunchAgent::new(
|
||||||
|
Arc::new(contexts),
|
||||||
|
Arc::new(profiles),
|
||||||
|
Arc::new(runtime),
|
||||||
|
Arc::new(fs),
|
||||||
|
Arc::new(pty),
|
||||||
|
Arc::new(FakeSkills),
|
||||||
|
Arc::clone(&sessions),
|
||||||
|
Arc::new(bus),
|
||||||
|
Arc::new(SeqIds::new()),
|
||||||
|
Arc::new(FakeRecall),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_structured(Arc::new(factory), Arc::clone(&structured));
|
||||||
|
if let Some(store) = store {
|
||||||
|
let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||||
|
launch = launch.with_provider_session_provider(provider);
|
||||||
|
}
|
||||||
|
(Arc::new(launch), agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant
|
||||||
|
/// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id`
|
||||||
|
/// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de
|
||||||
|
/// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||||
|
let (launch, agent) =
|
||||||
|
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
// La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.get_sync(pair, "claude").as_deref(),
|
||||||
|
Some("engine-xyz"),
|
||||||
|
"engine resumable stored under (pair, claude)"
|
||||||
|
);
|
||||||
|
assert_eq!(store.len(), 1, "exactly one entry written");
|
||||||
|
// La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas
|
||||||
|
// l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien.
|
||||||
|
assert!(
|
||||||
|
store.get_sync(pair, "codex").is_none(),
|
||||||
|
"nothing written under a different provider key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider
|
||||||
|
/// `"codex"`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, Some("engine-codex"));
|
||||||
|
let (launch, agent) = launch_fixture_p8b(
|
||||||
|
structured_codex_profile(pid(9)),
|
||||||
|
factory,
|
||||||
|
Some(Arc::clone(&store)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.get_sync(pair, "codex").as_deref(),
|
||||||
|
Some("engine-codex"),
|
||||||
|
"engine resumable stored under (pair, codex)"
|
||||||
|
);
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **provider_key correct** : test direct du contrat de persistance.
|
||||||
|
#[test]
|
||||||
|
fn p8b_provider_key_mapping_is_stable() {
|
||||||
|
assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
|
||||||
|
assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
|
||||||
|
/// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une
|
||||||
|
/// copie pour le prouver).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||||
|
// store NON câblé (None) ⇒ aucune écriture possible.
|
||||||
|
let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None);
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("launch ok without provider");
|
||||||
|
|
||||||
|
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **No-op sans id moteur** : la session fake n'expose aucun id
|
||||||
|
/// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
// Factory avec conversation_id None ⇒ session.conversation_id() == None.
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent) =
|
||||||
|
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("launch ok without engine id");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.len(),
|
||||||
|
0,
|
||||||
|
"no engine session id ⇒ persist skipped, launch unaffected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand
|
||||||
|
/// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent,
|
||||||
|
/// engine_session_id exposé).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8b_store_set_error_does_not_fail_launch() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::failing());
|
||||||
|
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||||
|
let (launch, agent) =
|
||||||
|
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
// Le lancement réussit malgré l'échec d'écriture du resumable.
|
||||||
|
let out = launch
|
||||||
|
.execute(input)
|
||||||
|
.await
|
||||||
|
.expect("launch must succeed despite store set failure");
|
||||||
|
|
||||||
|
// Sortie non dégradée : descripteur structuré + id moteur toujours exposés.
|
||||||
|
let desc = out.structured.expect("structured descriptor still present");
|
||||||
|
assert_eq!(desc.session_id, sid(500));
|
||||||
|
assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz"));
|
||||||
|
// Le store n'a (logiquement) rien persisté.
|
||||||
|
assert_eq!(store.len(), 0, "failing set wrote nothing");
|
||||||
|
}
|
||||||
|
|||||||
@ -134,6 +134,24 @@ pub enum StructuredAdapter {
|
|||||||
Codex,
|
Codex,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StructuredAdapter {
|
||||||
|
/// Clé **stable par famille de moteur** (provider), indépendante de l'uuid
|
||||||
|
/// d'instance d'un profil. Sert de clé dans `providers.json` (lot P8b) : un swap
|
||||||
|
/// d'un profil vers un autre profil de **la même famille** réutilise la même
|
||||||
|
/// entrée (resumable partagé), et le fichier reste lisible à l'œil.
|
||||||
|
///
|
||||||
|
/// Mapping : `Claude → "claude"`, `Codex → "codex"` (aligné sur la sérialisation
|
||||||
|
/// camelCase de l'enum, mais figé ici comme **contrat de persistance** : ce
|
||||||
|
/// mapping ne doit pas dériver si la représentation serde change).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn provider_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Claude => "claude",
|
||||||
|
Self::Codex => "codex",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
|
/// Transport du serveur MCP IdeA exposé à une CLI (détail invisible au domaine).
|
||||||
///
|
///
|
||||||
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
|
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
|
||||||
|
|||||||
Reference in New Issue
Block a user