fix(domain,application,infrastructure,app-tauri,frontend): conversation_id redevient un pivot logique IdeA/log/handoff, reprise provider réservée à la sélection explicite — lifecycle: LaunchAgentInput::conversation_id documenté comme sélection explicite (Some = reprise native si disponible sinon handoff portable, None = conversation provider neuve), plus de copie implicite du pivot logique de la leaf au reboot ; fallback silencieux: provider_resume_fallback résolu via resolve_handoff et câblé jusqu'aux adaptateurs Claude/Codex/OpenCode via AgentSessionFactory::create resume_fallback_preamble + with_resume_fallback_preamble — le préambule reste dormant si la session native répond et n'est injecté que si la session provider s'avère introuvable/périmée (fake stale-then-fresh en test, relance fraîche sans erreur utilisateur) ; app-tauri launch_agent: contrat documenté, l'activation ordinaire d'une cellule doit envoyer None même si la leaf conserve un pivot logique d'avant reboot ; frontend CustomAgentChatView: freshOnOpen réinitialise l'état conversation complet (scrollback, view state, conversationId publié, bannière reprise, pagination historique) quand le runtime structuré n'existe plus au remontage — pas de fausse « Conversation reprise » après reboot ; tests: structured_launch_d3 étendu, structured_session_d0, agent_lifecycle structured, CustomAgentChatView freshOnOpen — #372 (QA verte: cargo check --tests -p domain -p application -p infrastructure, cargo test -p application --test structured_launch_d3, cargo test -p domain --test structured_session_d0, cargo test -p application --test agent_lifecycle structured, npm test -- CustomAgentChatView, npm run typecheck, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)

This commit is contained in:
2026-09-17 19:43:14 +02:00
parent 0914a77d9f
commit 07488769d3
19 changed files with 483 additions and 57 deletions

View File

@ -682,6 +682,7 @@ impl AgentSessionFactory for FakeStructuredFactory {
_ctx: &PreparedContext,
_cwd: &ProjectPath,
session: &SessionPlan,
_resume_fallback_preamble: Option<&str>,
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,

View File

@ -2205,6 +2205,7 @@ impl AgentSessionFactory for CompletionFactory {
ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_resume_fallback_preamble: Option<&str>,
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
@ -4903,6 +4904,7 @@ impl AgentSessionFactory for CountingFactory {
_ctx: &PreparedContext,
_cwd: &ProjectPath,
session: &SessionPlan,
_resume_fallback_preamble: Option<&str>,
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,

View File

@ -47,13 +47,13 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
DefaultAgentTerminalInterface, IdeaUserSettings, MemoryIndexEntry, NodeId, PtySize, SessionId,
SessionKind, SkillId,
DefaultAgentTerminalInterface, Handoff, HandoffStore, IdeaUserSettings, MemoryIndexEntry,
NodeId, PtySize, SessionId, SessionKind, SkillId, TurnId,
};
use uuid::Uuid;
use application::{
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput,
ChangeAgentProfile, ChangeAgentProfileInput, HandoffProvider, LaunchAgent, LaunchAgentInput,
ManagedTempExposureRequest, StructuredRoutingMode, StructuredSessions, TerminalSessions,
};
@ -496,6 +496,8 @@ struct FakeFactory {
conversation_id: Option<String>,
/// Trace des `(command, SessionPlan)` reçus par `start`.
starts: Arc<Mutex<Vec<(String, SessionPlan)>>>,
/// Portable fallback passed alongside each native provider resume.
resume_fallbacks: Arc<Mutex<Vec<Option<String>>>>,
/// Compteur de `shutdown()` partagé avec les sessions créées.
shutdowns: Arc<AtomicUsize>,
}
@ -506,6 +508,7 @@ impl FakeFactory {
next_id: Arc::new(Mutex::new(first_id)),
conversation_id: conversation_id.map(str::to_owned),
starts: Arc::new(Mutex::new(Vec::new())),
resume_fallbacks: Arc::new(Mutex::new(Vec::new())),
shutdowns: Arc::new(AtomicUsize::new(0)),
}
}
@ -515,6 +518,9 @@ impl FakeFactory {
fn starts(&self) -> Vec<(String, SessionPlan)> {
self.starts.lock().unwrap().clone()
}
fn resume_fallbacks(&self) -> Vec<Option<String>> {
self.resume_fallbacks.lock().unwrap().clone()
}
fn shutdown_count(&self) -> usize {
self.shutdowns.load(Ordering::SeqCst)
}
@ -531,6 +537,7 @@ impl AgentSessionFactory for FakeFactory {
_ctx: &PreparedContext,
_cwd: &ProjectPath,
session: &SessionPlan,
resume_fallback_preamble: Option<&str>,
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
@ -540,6 +547,10 @@ impl AgentSessionFactory for FakeFactory {
.lock()
.unwrap()
.push((profile.command.clone(), session.clone()));
self.resume_fallbacks
.lock()
.unwrap()
.push(resume_fallback_preamble.map(str::to_owned));
let id = {
let mut n = self.next_id.lock().unwrap();
let id = SessionId::from_uuid(Uuid::from_u128(*n));
@ -1422,6 +1433,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
&cwd,
&SessionPlan::None,
None,
None,
&[],
None,
None,
@ -1916,6 +1928,7 @@ fn launch_fixture_with_provider(
profile: AgentProfile,
factory: FakeFactory,
provider: Arc<dyn ProviderSessionProvider>,
handoffs: Option<Arc<dyn HandoffProvider>>,
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
let observed = factory.clone();
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
@ -1927,7 +1940,7 @@ fn launch_fixture_with_provider(
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let bus = SpyBus::default();
let launch = LaunchAgent::new(
let mut launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(runtime),
@ -1943,9 +1956,87 @@ fn launch_fixture_with_provider(
.with_structured(Arc::new(factory), Arc::clone(&structured))
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_provider_session_provider(provider);
if let Some(handoffs) = handoffs {
launch = launch.with_handoff_provider(handoffs);
}
(Arc::new(launch), agent, observed)
}
#[derive(Clone)]
struct SingleHandoffStore {
conversation: ConversationId,
handoff: Handoff,
}
#[async_trait]
impl HandoffStore for SingleHandoffStore {
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
Ok((conversation == self.conversation).then(|| self.handoff.clone()))
}
async fn save(
&self,
_conversation: ConversationId,
_handoff: Handoff,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Clone)]
struct SingleHandoffProvider(Arc<dyn HandoffStore>);
impl HandoffProvider for SingleHandoffProvider {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
Some(Arc::clone(&self.0))
}
}
#[tokio::test]
async fn explicit_structured_resume_supplies_handoff_only_as_provider_fallback() {
let provider_store = Arc::new(FakeProviderSessionStore::new());
let provider = Arc::new(FakeProviderSessionProvider(Arc::clone(&provider_store)))
as Arc<dyn ProviderSessionProvider>;
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
provider_store.seed_sync(pair, "claude", "stale-engine-session");
let handoff = Handoff::new(
"Résumé portable du travail.",
TurnId::from_uuid(Uuid::from_u128(7)),
Some("Continuer".to_owned()),
);
let handoffs = Arc::new(SingleHandoffProvider(Arc::new(SingleHandoffStore {
conversation: pair,
handoff,
}))) as Arc<dyn HandoffProvider>;
let factory = FakeFactory::new(500, None);
let (launch, agent, observed) = launch_fixture_with_provider(
structured_profile(pid(9)),
factory,
provider,
Some(handoffs),
);
let mut input = launch_input(agent.id);
input.require_structured = true;
input.conversation_id = Some(pair.to_string());
launch
.execute(input)
.await
.expect("explicit resume launches");
assert_eq!(
observed.starts()[0].1,
SessionPlan::Resume {
conversation_id: "stale-engine-session".to_owned()
}
);
assert_eq!(
observed.resume_fallbacks(),
vec![Some("Résumé portable du travail.".to_owned())],
"handoff stays dormant unless the provider rejects its native session"
);
}
/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
@ -2183,9 +2274,10 @@ async fn p8c_opencode_same_pair_and_provider_are_isolated_by_project_root() {
structured_opencode_profile(pid(9)),
factory.clone(),
Arc::clone(&provider),
None,
);
let (launch_b, agent_b, _) =
launch_fixture_with_provider(structured_opencode_profile(pid(9)), factory, provider);
launch_fixture_with_provider(structured_opencode_profile(pid(9)), factory, provider, None);
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
store_a.seed_sync(pair, "opencode", "engine-project-a");
store_b.seed_sync(pair, "opencode", "engine-project-b");

View File

@ -325,6 +325,7 @@ impl AgentSessionFactory for FakeFactory {
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
_resume_fallback_preamble: Option<&str>,
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>,