diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 2889268e..91c1767e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2688,8 +2688,9 @@ pub async fn launch_agent( rows: request.rows, cols: request.cols, node_id, - // Resume id is a property of the hosting cell; the frontend passes the - // leaf's current conversation id here. + // `Some` is an explicit user resume selection. Ordinary cell activation + // must send `None`, even when the leaf retains a logical conversation + // pivot from before a reboot. conversation_id: request.conversation_id.clone(), mcp_runtime, allow_structured_alongside_pty: false, diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 98ff6d26..f2ede430 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1280,11 +1280,11 @@ pub struct LaunchAgentInput { pub cols: u16, /// The layout leaf hosting the session (a fresh node when `None`). pub node_id: Option, - /// The persistent CLI conversation id currently recorded on the hosting cell, - /// if any. `Some` means a previous conversation exists and the launch should - /// **resume** it; `None` means a fresh cell (the launch may *assign* a new id - /// when the profile supports it). The caller (which owns the layout) reads this - /// from the leaf's [`domain::layout::LeafCell::conversation_id`]. + /// The logical IdeA conversation selected for an **explicit resume**. `Some` + /// requests resume (native provider session when available, portable handoff + /// otherwise); `None` requests a fresh provider conversation. A caller must not + /// copy [`domain::layout::LeafCell::conversation_id`] here merely because the + /// hosting cell still carries that logical log/handoff pivot after a reboot. pub conversation_id: Option, /// Runtime facts needed to write the **real** IdeA MCP server declaration /// (M5d). These are **OS/runtime data** (the IdeA executable path, the @@ -2505,10 +2505,9 @@ impl LaunchAgent { relative_path: agent.context_path.clone(), project_root: input.project.root.as_str().to_owned(), }; - // 4a. Resolve the session intention (T4). The conversation id is a property - // of the *cell*, not the PTY: the caller (which owns the layout) passes - // the cell's current `conversation_id`. Any id this launch *assigns* is - // returned in the output so the caller persists it on the leaf. + // 4a. Resolve the session intention (T4). An input conversation id is an + // explicit resume selection, not an implicit copy of the host leaf's + // logical pivot. Any id this launch assigns is returned for persistence. let (session_plan, assigned_conversation_id) = self .resolve_session_plan( &profile, @@ -2521,14 +2520,26 @@ impl LaunchAgent { // resume is already represented by `SessionPlan::Resume`; otherwise retain a // bounded one-shot preamble for the first subsequent `send`. let defer_custom_resume = input.require_structured && launch_conversation_id.is_some(); - let pending_resume_preamble = - if defer_custom_resume && !matches!(session_plan, SessionPlan::Resume { .. }) { - self.resolve_handoff(&input.project.root, launch_conversation_id.as_deref()) - .await - .map(|handoff| handoff.summary_md) - } else { - None - }; + let resume_handoff = if defer_custom_resume { + self.resolve_handoff(&input.project.root, launch_conversation_id.as_deref()) + .await + .map(|handoff| handoff.summary_md) + } else { + None + }; + // Without a native provider resumable, the command layer injects this on + // the first send. With a native resumable, the adapter keeps it dormant + // and uses it only if that provider session proves stale. + let pending_resume_preamble = if matches!(session_plan, SessionPlan::Resume { .. }) { + None + } else { + resume_handoff.clone() + }; + let provider_resume_fallback = if matches!(session_plan, SessionPlan::Resume { .. }) { + resume_handoff + } else { + None + }; let mut spec = self.runtime .prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?; @@ -2773,6 +2784,7 @@ impl LaunchAgent { &prepared, &run_dir, &session_plan, + provider_resume_fallback.as_deref(), pair_conversation_id, input.project.id, &input.project.root, @@ -2878,6 +2890,7 @@ impl LaunchAgent { prepared: &PreparedContext, run_dir: &ProjectPath, session_plan: &SessionPlan, + resume_fallback_preamble: Option<&str>, pair_conversation_id: String, project_id: domain::ProjectId, root: &ProjectPath, @@ -2900,6 +2913,7 @@ impl LaunchAgent { prepared, run_dir, session_plan, + resume_fallback_preamble, Some(&agent.id.to_string()), env, sandbox, diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index a83d15bc..caea451a 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -179,6 +179,7 @@ impl OpenTicketAssistant { &prepared, &environment.cwd, &SessionPlan::None, + None, Some(&requester), &environment.env, None, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 95c3afa3..48d0ce59 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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>, diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index ba71443d..651cc658 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -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>, diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index b2696a54..06603dba 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -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, /// Trace des `(command, SessionPlan)` reçus par `start`. starts: Arc>>, + /// Portable fallback passed alongside each native provider resume. + resume_fallbacks: Arc>>>, /// Compteur de `shutdown()` partagé avec les sessions créées. shutdowns: Arc, } @@ -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> { + 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, + handoffs: Option>, ) -> (Arc, 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, 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); + +impl HandoffProvider for SingleHandoffProvider { + fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { + 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; + 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; + 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"); diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 19a84196..1007ce22 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -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>, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 1501c541..27fe59c3 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1709,6 +1709,9 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + // Portable context used only when a native provider resume points to a + // session that no longer exists; never eagerly injected on success. + resume_fallback_preamble: Option<&str>, requester: Option<&str>, env: &[(String, String)], sandbox: Option<&crate::sandbox::SandboxPlan>, diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index 7d9e736b..3f44bf76 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -340,6 +340,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>, @@ -405,6 +406,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() { &cwd, &SessionPlan::None, None, + None, &[], None, None, diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index b6b76ac7..34ecc013 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -25,7 +25,10 @@ use domain::ports::{ use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; -use super::process::{run_turn_cancellable, SpawnLine, TurnCancel}; +use super::process::{ + is_missing_provider_session, resume_fallback_prompt, run_turn_cancellable, SpawnLine, + TurnCancel, +}; /// Résultat du parsing d'une ligne : **zéro ou plusieurs** événements à émettre et/ou /// un `session_id` capté (init/result). Permet à [`parse_event`] de rester **pure** @@ -300,6 +303,8 @@ pub struct ClaudeSdkSession { cwd: String, /// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Portable handoff injected only if the seeded provider session is stale. + resume_fallback_preamble: Option, /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). sandbox: Option, @@ -330,12 +335,20 @@ impl ClaudeSdkSession { command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), + resume_fallback_preamble: None, sandbox, sandbox_enforcer, turn_cancel: TurnCancel::new(), } } + /// Adds the portable context used by the one-shot stale-resume fallback. + #[must_use] + pub fn with_resume_fallback_preamble(mut self, preamble: Option<&str>) -> Self { + self.resume_fallback_preamble = preamble.map(str::to_owned); + self + } + /// Compose la ligne de commande d'un tour selon l'état de conversation. /// /// Format RÉEL vérifié 2026-06-09 : @@ -408,6 +421,7 @@ impl ClaudeSdkSession { prompt: &str, tap: Option>, ) -> Result { + let had_native_resume = self.conversation_id().is_some(); let spec = self.build_spawn_line(prompt); let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| { let (line_tx, line_rx) = std::sync::mpsc::channel::(); @@ -424,14 +438,27 @@ impl ClaudeSdkSession { }); (Some(line_tx), Some(parser)) }); - let raw_result = run_turn_cancellable( + let mut raw_result = run_turn_cancellable( &spec, None, self.sandbox_enforcer.as_ref(), - line_tap, + line_tap.clone(), &self.turn_cancel, ) .await; + if had_native_resume && raw_result.as_ref().is_err_and(is_missing_provider_session) { + *self.conversation_id.lock().expect("mutex sain") = None; + let fallback_prompt = + resume_fallback_prompt(self.resume_fallback_preamble.as_deref(), prompt); + raw_result = run_turn_cancellable( + &self.build_spawn_line(&fallback_prompt), + None, + self.sandbox_enforcer.as_ref(), + line_tap, + &self.turn_cancel, + ) + .await; + } if let Some(parser) = parser_thread { let _ = parser.join(); } diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 56a45fb6..bc9517e0 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -22,7 +22,10 @@ use domain::ports::{ use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; -use super::process::{run_turn_cancellable, SpawnLine, TurnCancel}; +use super::process::{ + is_missing_provider_session, resume_fallback_prompt, run_turn_cancellable, SpawnLine, + TurnCancel, +}; /// Résultat du parsing d'une ligne Codex : **zéro ou plusieurs** événements et/ou un /// id de conversation Codex capté. Miroir de `claude::ParsedLine` (vecteur d'events @@ -401,6 +404,8 @@ pub struct CodexExecSession { env: Vec<(String, String)>, /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Portable handoff injected only if the seeded provider session is stale. + resume_fallback_preamble: Option, /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). sandbox: Option, @@ -503,12 +508,20 @@ impl CodexExecSession { model_reasoning_effort, env, conversation_id: Mutex::new(seed_conversation_id), + resume_fallback_preamble: None, sandbox, sandbox_enforcer, turn_cancel: TurnCancel::new(), } } + /// Adds the portable context used by the one-shot stale-resume fallback. + #[must_use] + pub fn with_resume_fallback_preamble(mut self, preamble: Option<&str>) -> Self { + self.resume_fallback_preamble = preamble.map(str::to_owned); + self + } + /// Compose la ligne de commande d'un tour. /// /// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) : @@ -641,6 +654,7 @@ impl CodexExecSession { prompt: &str, tap: Option>, ) -> Result { + let had_native_resume = self.conversation_id().is_some(); let spec = self.build_spawn_line(prompt); let has_tap = tap.is_some(); let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| { @@ -670,14 +684,27 @@ impl CodexExecSession { }); (Some(line_tx), Some(parser)) }); - let raw_result = run_turn_cancellable( + let mut raw_result = run_turn_cancellable( &spec, None, self.sandbox_enforcer.as_ref(), - line_tap, + line_tap.clone(), &self.turn_cancel, ) .await; + if had_native_resume && raw_result.as_ref().is_err_and(is_missing_provider_session) { + *self.conversation_id.lock().expect("mutex sain") = None; + let fallback_prompt = + resume_fallback_prompt(self.resume_fallback_preamble.as_deref(), prompt); + raw_result = run_turn_cancellable( + &self.build_spawn_line(&fallback_prompt), + None, + self.sandbox_enforcer.as_ref(), + line_tap, + &self.turn_cancel, + ) + .await; + } if let Some(parser) = parser_thread { let _ = parser.join(); } diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index e3d1d9e6..dd845df9 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -142,6 +142,7 @@ impl AgentSessionFactory for StructuredSessionFactory { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + resume_fallback_preamble: Option<&str>, requester: Option<&str>, env: &[(String, String)], sandbox: Option<&SandboxPlan>, @@ -175,9 +176,10 @@ impl AgentSessionFactory for StructuredSessionFactory { // injection supplémentaire n'incombe ici en mode structuré (la CLI lit son // fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd). let session: Arc = match adapter { - StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new( - id, command, cwd, seed, plan, enforcer, - )), + StructuredAdapter::Claude => Arc::new( + ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer) + .with_resume_fallback_preamble(resume_fallback_preamble), + ), StructuredAdapter::Codex => { let policy = match structured_policy { Some(StructuredProviderLaunchPolicy::Codex { @@ -195,32 +197,38 @@ impl AgentSessionFactory for StructuredSessionFactory { )); } }; - Arc::new(CodexExecSession::new_with_policy_and_overrides( + Arc::new( + CodexExecSession::new_with_policy_and_overrides( + id, + command, + cwd, + seed, + policy.0, + policy.1, + policy.2, + profile.model.clone(), + profile.model_reasoning_effort.clone(), + env.to_vec(), + plan, + enforcer, + ) + .with_resume_fallback_preamble(resume_fallback_preamble), + ) + } + StructuredAdapter::OpenCode => Arc::new( + OpenCodeSession::new( id, - command, - cwd, + profile.command.clone(), + profile.args.clone(), + cwd.clone(), + ctx.project_root.clone(), seed, - policy.0, - policy.1, - policy.2, - profile.model.clone(), - profile.model_reasoning_effort.clone(), env.to_vec(), plan, enforcer, - )) - } - StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new( - id, - profile.command.clone(), - profile.args.clone(), - cwd.clone(), - ctx.project_root.clone(), - seed, - env.to_vec(), - plan, - enforcer, - )?), + )? + .with_resume_fallback_preamble(resume_fallback_preamble), + ), StructuredAdapter::OpenAiCompatible => { let config = profile.chat_http.clone().ok_or_else(|| { AgentSessionError::Start(format!( diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 09080fae..4cb343b9 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -577,6 +577,7 @@ mod tests { &cwd(), &SessionPlan::None, None, + None, &[], None, None, @@ -601,6 +602,7 @@ mod tests { &cwd(), &SessionPlan::None, None, + None, &[], None, Some(&codex_policy), @@ -634,6 +636,7 @@ mod tests { &temp_cwd("factory-openai"), &SessionPlan::None, None, + None, &[], None, None, @@ -672,6 +675,7 @@ mod tests { &cwd(), &SessionPlan::None, None, + None, &[], None, None, @@ -723,6 +727,7 @@ mod tests { &cwd(), &SessionPlan::None, None, + None, &[], None, Some(&policy), @@ -762,6 +767,7 @@ mod tests { conversation_id: "repris-42".to_owned(), }, None, + None, &[], None, None, @@ -916,6 +922,89 @@ mod tests { (bin.to_string_lossy().into_owned(), argv) } + /// Fake provider that rejects its first (resume) invocation as stale, then + /// succeeds as a fresh Codex conversation on the retry. + fn make_stale_then_fresh_codex_fake() -> (String, std::path::PathBuf) { + use std::io::Write as _; + use std::sync::atomic::{AtomicU64, Ordering}; + static C: AtomicU64 = AtomicU64::new(0); + let n = C.fetch_add(1, Ordering::Relaxed); + let dir = std::env::current_dir() + .expect("cwd") + .join("target") + .join("test-fakes") + .join("session"); + std::fs::create_dir_all(&dir).expect("create stale fake dir"); + let bin = dir.join(format!("idea-stale-cli-{}-{n}", std::process::id())); + let argv = dir.join(format!("idea-stale-argv-{}-{n}", std::process::id())); + let marker = dir.join(format!("idea-stale-marker-{}-{n}", std::process::id())); + let script = format!( + "#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\" >> '{}'; done\n\ + if [ ! -f '{}' ]; then touch '{}'; printf '%s\\n' 'session not found' >&2; exit 1; fi\n\ + printf '%s\\n' '{{\"type\":\"thread.started\",\"thread_id\":\"fresh-thread\"}}'\n\ + printf '%s\\n' '{{\"type\":\"item.completed\",\"item\":{{\"id\":\"i0\",\"type\":\"agent_message\",\"text\":\"fallback ok\"}}}}'\n", + argv.display(), + marker.display(), + marker.display(), + ); + let mut file = std::fs::File::create(&bin).expect("create stale fake"); + file.write_all(script.as_bytes()).expect("write stale fake"); + file.sync_all().expect("sync stale fake"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&bin).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&bin, permissions).unwrap(); + } + super::conformance::wait_until_executable(&bin); + (bin.to_string_lossy().into_owned(), argv) + } + + #[tokio::test] + async fn stale_codex_resume_retries_fresh_with_handoff_without_surface_error() { + let factory = StructuredSessionFactory::new(); + let (command, argv) = make_stale_then_fresh_codex_fake(); + let profile = structured_profile(StructuredAdapter::Codex, &command); + let policy = StructuredProviderLaunchPolicy::Codex { + sandbox_mode: "read-only".to_owned(), + writable_roots: Vec::new(), + network_access: false, + }; + let session = factory + .start( + &profile, + &prepared_ctx(), + &cwd(), + &SessionPlan::Resume { + conversation_id: "stale-thread".to_owned(), + }, + Some("Résumé portable conservé."), + None, + &[], + None, + Some(&policy), + ) + .await + .expect("session adapter starts"); + + let stream = session + .send("Continuer le travail") + .await + .expect("stale provider resume falls back silently"); + assert!(stream.into_iter().any(|event| matches!( + event, + ReplyEvent::Final { content } if content == "fallback ok" + ))); + assert_eq!(session.conversation_id().as_deref(), Some("fresh-thread")); + + let recorded = std::fs::read_to_string(argv).expect("recorded argv"); + assert!(recorded.contains("resume\nstale-thread")); + assert!(recorded.contains("Contexte borné de la conversation reprise")); + assert!(recorded.contains("Résumé portable conservé.")); + assert!(recorded.contains("Continuer le travail")); + } + // ---- Claude parse_event : plusieurs blocs, robustesse --------------- /// Messages successifs : chaque message produit ses événements ; ici un par @@ -1848,6 +1937,7 @@ mod tests { conversation_id: "cx-resume".to_owned(), }, None, + None, &[], None, Some(&policy), @@ -1874,6 +1964,7 @@ mod tests { conversation_id: "ignored-by-engine".to_owned(), }, None, + None, &[], None, None, @@ -1907,6 +1998,7 @@ mod tests { &cwd(), &SessionPlan::None, None, + None, &[], None, None, diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 62c18ef6..1921716a 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -23,7 +23,7 @@ use domain::ports::{ use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; -use super::process::{TurnCancel, TurnLease}; +use super::process::{is_missing_provider_session, resume_fallback_prompt, TurnCancel, TurnLease}; /// Un événement OpenCode parsé depuis stdout JSONL. #[derive(Debug, Clone, PartialEq, Eq)] @@ -555,6 +555,8 @@ pub struct OpenCodeSession { project_root: String, env: Vec<(String, String)>, engine_session_id: Mutex>, + /// Portable handoff injected only if the seeded provider session is stale. + resume_fallback_preamble: Option, turn_cancel: TurnCancel, sandbox: Option, sandbox_enforcer: Option>, @@ -585,12 +587,20 @@ impl OpenCodeSession { project_root: project_root.into(), env, engine_session_id: Mutex::new(seed), + resume_fallback_preamble: None, turn_cancel: TurnCancel::new(), sandbox, sandbox_enforcer, }) } + /// Adds the portable context used by the one-shot stale-resume fallback. + #[must_use] + pub fn with_resume_fallback_preamble(mut self, preamble: Option<&str>) -> Self { + self.resume_fallback_preamble = preamble.map(str::to_owned); + self + } + fn build_args(&self, prompt: &str, engine_session_id: Option<&str>) -> Vec { let mut args = self.prefix_args.clone(); args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]); @@ -663,9 +673,16 @@ impl OpenCodeSession { Ok(events) => events, Err(err) if initial_session.is_some() - && matches!(err, AgentSessionError::Decode(ref message) if opencode_missing_final(message)) => + && (matches!(err, AgentSessionError::Decode(ref message) if opencode_missing_final(message)) + || is_missing_provider_session(&err)) => { - self.run_once(prompt, tap, None, &turn).await? + *self + .engine_session_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let fallback_prompt = + resume_fallback_prompt(self.resume_fallback_preamble.as_deref(), prompt); + self.run_once(&fallback_prompt, tap, None, &turn).await? } Err(err) => return Err(err), }; diff --git a/crates/infrastructure/src/session/process.rs b/crates/infrastructure/src/session/process.rs index f109cee7..54e9a0b7 100644 --- a/crates/infrastructure/src/session/process.rs +++ b/crates/infrastructure/src/session/process.rs @@ -722,11 +722,59 @@ fn process_exit_message(command: &str, code: Option, stderr: &[u8]) -> Stri } } +/// Whether a provider rejected a native resume because its remote/local session +/// no longer exists. Kept deliberately narrow: authentication, permissions and +/// generic process failures must remain visible instead of being retried. +pub(super) fn is_missing_provider_session(error: &AgentSessionError) -> bool { + let AgentSessionError::Io(message) = error else { + return false; + }; + let message = message.to_ascii_lowercase(); + [ + "session not found", + "thread not found", + "conversation not found", + "no session found", + "no thread found", + "no conversation found", + "unknown session", + "unknown thread", + "no rollout found with id", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +pub(super) fn resume_fallback_prompt(preamble: Option<&str>, prompt: &str) -> String { + preamble.map_or_else( + || prompt.to_owned(), + |summary| format!( + "Contexte borné de la conversation reprise :\n\n{summary}\n\n---\n\nNouveau message utilisateur :\n{prompt}" + ), + ) +} + #[cfg(test)] mod tests { use super::*; use std::time::Instant; + #[test] + fn missing_provider_session_classifier_is_narrow() { + assert!(is_missing_provider_session(&AgentSessionError::Io( + "codex exited with code 1: session not found".to_owned() + ))); + assert!(is_missing_provider_session(&AgentSessionError::Io( + "no rollout found with id abc".to_owned() + ))); + assert!(!is_missing_provider_session(&AgentSessionError::Io( + "permission denied".to_owned() + ))); + assert!(!is_missing_provider_session(&AgentSessionError::Start( + "session not found".to_owned() + ))); + } + #[tokio::test] async fn run_turn_cancellable_kills_current_child_without_waiting_for_natural_exit() { let spec = SpawnLine { diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs index a96868fa..0df89d42 100644 --- a/crates/infrastructure/src/session/sandbox_e2e.rs +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -476,6 +476,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() { &cwd, &SessionPlan::None, None, + None, &[], Some(&plan), None, diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 1690e364..50c6c199 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -496,6 +496,7 @@ impl AgentSessionFactory for BlockingReplyFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _resume_fallback_preamble: Option<&str>, _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index bc190d9c..32fb4393 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -6751,6 +6751,79 @@ describe("CustomAgentChatView", () => { )); }); + it("starts fresh after reboot when both persisted session and conversation are stale (#372)", async () => { + const conversation = { + readPage: vi.fn(), + }; + const agent = { + launchAgentChat: vi.fn(async () => ({ + sessionId: "fresh-session", + assignedConversationId: "fresh-conversation", + })), + reattachAgentChat: vi.fn() + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "session not found" }) + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "session not found" }) + .mockResolvedValueOnce({ sessionId: "fresh-session", scrollback: [] }), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => ({ + sessionId: "fresh-session", + outcome: "cancelled" as const, + })), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + const onConversationId = vi.fn(); + + render( + null) }, + } as unknown as Gateways}> + + , + ); + + await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); + expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", { + cwd: "/repo", + rows: 24, + cols: 80, + conversationId: undefined, + freshStart: true, + nodeId: "node-1", + }); + expect(conversation.readPage).not.toHaveBeenCalled(); + expect(screen.queryByText("Conversation reprise")).toBeNull(); + expect(screen.queryByText("session not found")).toBeNull(); + expect(await screen.findByText("Nouvelle conversation")).toBeTruthy(); + expect(onSessionId).toHaveBeenCalledWith(null); + expect(onConversationId).toHaveBeenCalledWith(null); + expect(onConversationId).toHaveBeenCalledWith("fresh-conversation"); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Ready now" } }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( + "fresh-session", + "Ready now", + expect.any(Function), + )); + }); + it("uses /clean for a new conversation and disables conversation changes during a turn", async () => { let finishSend: (() => void) | undefined; const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 2919f3c0..e1c5dabd 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -2238,6 +2238,20 @@ export function CustomAgentChatView({ setExternalSessionId(null); setLiveSessionAttached(false); publishSessionId(null); + if (freshOnOpen) { + // After an application reboot, the leaf can still contain both + // ids even though the structured runtime no longer exists. A + // normal cell opening must not turn that stale runtime pivot + // into an implicit conversation resume (#372). + scrollbackChunkCountRef.current = 0; + applyChatViewState(emptyChatViewState()); + publishConversationId(null); + setSavedConversationLoadState("idle"); + setResumedConversation(false); + setHistoryHasMore(false); + setHistoryNextAnchor(undefined); + setHistoryError(null); + } } console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { timestamp: new Date().toISOString(), @@ -2397,6 +2411,7 @@ export function CustomAgentChatView({ reattachStructuredSession, recoverStructuredSession, publishSessionId, + publishConversationId, applyChatViewState, conversation, agent,