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:
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1170,3 +1251,204 @@ async fn structured_profile_fresh_cell_resolves_to_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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user