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:
@ -2688,8 +2688,9 @@ pub async fn launch_agent(
|
|||||||
rows: request.rows,
|
rows: request.rows,
|
||||||
cols: request.cols,
|
cols: request.cols,
|
||||||
node_id,
|
node_id,
|
||||||
// Resume id is a property of the hosting cell; the frontend passes the
|
// `Some` is an explicit user resume selection. Ordinary cell activation
|
||||||
// leaf's current conversation id here.
|
// must send `None`, even when the leaf retains a logical conversation
|
||||||
|
// pivot from before a reboot.
|
||||||
conversation_id: request.conversation_id.clone(),
|
conversation_id: request.conversation_id.clone(),
|
||||||
mcp_runtime,
|
mcp_runtime,
|
||||||
allow_structured_alongside_pty: false,
|
allow_structured_alongside_pty: false,
|
||||||
|
|||||||
@ -1280,11 +1280,11 @@ pub struct LaunchAgentInput {
|
|||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
/// The layout leaf hosting the session (a fresh node when `None`).
|
/// The layout leaf hosting the session (a fresh node when `None`).
|
||||||
pub node_id: Option<NodeId>,
|
pub node_id: Option<NodeId>,
|
||||||
/// The persistent CLI conversation id currently recorded on the hosting cell,
|
/// The logical IdeA conversation selected for an **explicit resume**. `Some`
|
||||||
/// if any. `Some` means a previous conversation exists and the launch should
|
/// requests resume (native provider session when available, portable handoff
|
||||||
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
|
/// otherwise); `None` requests a fresh provider conversation. A caller must not
|
||||||
/// when the profile supports it). The caller (which owns the layout) reads this
|
/// copy [`domain::layout::LeafCell::conversation_id`] here merely because the
|
||||||
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
/// hosting cell still carries that logical log/handoff pivot after a reboot.
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
/// Runtime facts needed to write the **real** IdeA MCP server declaration
|
/// Runtime facts needed to write the **real** IdeA MCP server declaration
|
||||||
/// (M5d). These are **OS/runtime data** (the IdeA executable path, the
|
/// (M5d). These are **OS/runtime data** (the IdeA executable path, the
|
||||||
@ -2505,10 +2505,9 @@ impl LaunchAgent {
|
|||||||
relative_path: agent.context_path.clone(),
|
relative_path: agent.context_path.clone(),
|
||||||
project_root: input.project.root.as_str().to_owned(),
|
project_root: input.project.root.as_str().to_owned(),
|
||||||
};
|
};
|
||||||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
// 4a. Resolve the session intention (T4). An input conversation id is an
|
||||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
// explicit resume selection, not an implicit copy of the host leaf's
|
||||||
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
// logical pivot. Any id this launch assigns is returned for persistence.
|
||||||
// returned in the output so the caller persists it on the leaf.
|
|
||||||
let (session_plan, assigned_conversation_id) = self
|
let (session_plan, assigned_conversation_id) = self
|
||||||
.resolve_session_plan(
|
.resolve_session_plan(
|
||||||
&profile,
|
&profile,
|
||||||
@ -2521,14 +2520,26 @@ impl LaunchAgent {
|
|||||||
// resume is already represented by `SessionPlan::Resume`; otherwise retain a
|
// resume is already represented by `SessionPlan::Resume`; otherwise retain a
|
||||||
// bounded one-shot preamble for the first subsequent `send`.
|
// bounded one-shot preamble for the first subsequent `send`.
|
||||||
let defer_custom_resume = input.require_structured && launch_conversation_id.is_some();
|
let defer_custom_resume = input.require_structured && launch_conversation_id.is_some();
|
||||||
let pending_resume_preamble =
|
let resume_handoff = if defer_custom_resume {
|
||||||
if defer_custom_resume && !matches!(session_plan, SessionPlan::Resume { .. }) {
|
self.resolve_handoff(&input.project.root, launch_conversation_id.as_deref())
|
||||||
self.resolve_handoff(&input.project.root, launch_conversation_id.as_deref())
|
.await
|
||||||
.await
|
.map(|handoff| handoff.summary_md)
|
||||||
.map(|handoff| handoff.summary_md)
|
} else {
|
||||||
} else {
|
None
|
||||||
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 =
|
let mut spec =
|
||||||
self.runtime
|
self.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||||
@ -2773,6 +2784,7 @@ impl LaunchAgent {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&run_dir,
|
&run_dir,
|
||||||
&session_plan,
|
&session_plan,
|
||||||
|
provider_resume_fallback.as_deref(),
|
||||||
pair_conversation_id,
|
pair_conversation_id,
|
||||||
input.project.id,
|
input.project.id,
|
||||||
&input.project.root,
|
&input.project.root,
|
||||||
@ -2878,6 +2890,7 @@ impl LaunchAgent {
|
|||||||
prepared: &PreparedContext,
|
prepared: &PreparedContext,
|
||||||
run_dir: &ProjectPath,
|
run_dir: &ProjectPath,
|
||||||
session_plan: &SessionPlan,
|
session_plan: &SessionPlan,
|
||||||
|
resume_fallback_preamble: Option<&str>,
|
||||||
pair_conversation_id: String,
|
pair_conversation_id: String,
|
||||||
project_id: domain::ProjectId,
|
project_id: domain::ProjectId,
|
||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
@ -2900,6 +2913,7 @@ impl LaunchAgent {
|
|||||||
prepared,
|
prepared,
|
||||||
run_dir,
|
run_dir,
|
||||||
session_plan,
|
session_plan,
|
||||||
|
resume_fallback_preamble,
|
||||||
Some(&agent.id.to_string()),
|
Some(&agent.id.to_string()),
|
||||||
env,
|
env,
|
||||||
sandbox,
|
sandbox,
|
||||||
|
|||||||
@ -179,6 +179,7 @@ impl OpenTicketAssistant {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&environment.cwd,
|
&environment.cwd,
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
|
None,
|
||||||
Some(&requester),
|
Some(&requester),
|
||||||
&environment.env,
|
&environment.env,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -682,6 +682,7 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
|
|||||||
@ -2205,6 +2205,7 @@ impl AgentSessionFactory for CompletionFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
@ -4903,6 +4904,7 @@ impl AgentSessionFactory for CountingFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
|
|||||||
@ -47,13 +47,13 @@ use domain::project::{Project, ProjectPath};
|
|||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::skill::{Skill, SkillScope};
|
use domain::skill::{Skill, SkillScope};
|
||||||
use domain::{
|
use domain::{
|
||||||
DefaultAgentTerminalInterface, IdeaUserSettings, MemoryIndexEntry, NodeId, PtySize, SessionId,
|
DefaultAgentTerminalInterface, Handoff, HandoffStore, IdeaUserSettings, MemoryIndexEntry,
|
||||||
SessionKind, SkillId,
|
NodeId, PtySize, SessionId, SessionKind, SkillId, TurnId,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput,
|
ChangeAgentProfile, ChangeAgentProfileInput, HandoffProvider, LaunchAgent, LaunchAgentInput,
|
||||||
ManagedTempExposureRequest, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
ManagedTempExposureRequest, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -496,6 +496,8 @@ struct FakeFactory {
|
|||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
/// Trace des `(command, SessionPlan)` reçus par `start`.
|
/// Trace des `(command, SessionPlan)` reçus par `start`.
|
||||||
starts: Arc<Mutex<Vec<(String, SessionPlan)>>>,
|
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.
|
/// Compteur de `shutdown()` partagé avec les sessions créées.
|
||||||
shutdowns: Arc<AtomicUsize>,
|
shutdowns: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
@ -506,6 +508,7 @@ impl FakeFactory {
|
|||||||
next_id: Arc::new(Mutex::new(first_id)),
|
next_id: Arc::new(Mutex::new(first_id)),
|
||||||
conversation_id: conversation_id.map(str::to_owned),
|
conversation_id: conversation_id.map(str::to_owned),
|
||||||
starts: Arc::new(Mutex::new(Vec::new())),
|
starts: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
resume_fallbacks: Arc::new(Mutex::new(Vec::new())),
|
||||||
shutdowns: Arc::new(AtomicUsize::new(0)),
|
shutdowns: Arc::new(AtomicUsize::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -515,6 +518,9 @@ impl FakeFactory {
|
|||||||
fn starts(&self) -> Vec<(String, SessionPlan)> {
|
fn starts(&self) -> Vec<(String, SessionPlan)> {
|
||||||
self.starts.lock().unwrap().clone()
|
self.starts.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
fn resume_fallbacks(&self) -> Vec<Option<String>> {
|
||||||
|
self.resume_fallbacks.lock().unwrap().clone()
|
||||||
|
}
|
||||||
fn shutdown_count(&self) -> usize {
|
fn shutdown_count(&self) -> usize {
|
||||||
self.shutdowns.load(Ordering::SeqCst)
|
self.shutdowns.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
@ -531,6 +537,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
@ -540,6 +547,10 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push((profile.command.clone(), session.clone()));
|
.push((profile.command.clone(), session.clone()));
|
||||||
|
self.resume_fallbacks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(resume_fallback_preamble.map(str::to_owned));
|
||||||
let id = {
|
let id = {
|
||||||
let mut n = self.next_id.lock().unwrap();
|
let mut n = self.next_id.lock().unwrap();
|
||||||
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
let id = SessionId::from_uuid(Uuid::from_u128(*n));
|
||||||
@ -1422,6 +1433,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|||||||
&cwd,
|
&cwd,
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -1916,6 +1928,7 @@ fn launch_fixture_with_provider(
|
|||||||
profile: AgentProfile,
|
profile: AgentProfile,
|
||||||
factory: FakeFactory,
|
factory: FakeFactory,
|
||||||
provider: Arc<dyn ProviderSessionProvider>,
|
provider: Arc<dyn ProviderSessionProvider>,
|
||||||
|
handoffs: Option<Arc<dyn HandoffProvider>>,
|
||||||
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
||||||
let observed = factory.clone();
|
let observed = factory.clone();
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
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 sessions = Arc::new(TerminalSessions::new());
|
||||||
let structured = Arc::new(StructuredSessions::new());
|
let structured = Arc::new(StructuredSessions::new());
|
||||||
let bus = SpyBus::default();
|
let bus = SpyBus::default();
|
||||||
let launch = LaunchAgent::new(
|
let mut launch = LaunchAgent::new(
|
||||||
Arc::new(contexts),
|
Arc::new(contexts),
|
||||||
Arc::new(profiles),
|
Arc::new(profiles),
|
||||||
Arc::new(runtime),
|
Arc::new(runtime),
|
||||||
@ -1943,9 +1956,87 @@ fn launch_fixture_with_provider(
|
|||||||
.with_structured(Arc::new(factory), Arc::clone(&structured))
|
.with_structured(Arc::new(factory), Arc::clone(&structured))
|
||||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
.with_provider_session_provider(provider);
|
.with_provider_session_provider(provider);
|
||||||
|
if let Some(handoffs) = handoffs {
|
||||||
|
launch = launch.with_handoff_provider(handoffs);
|
||||||
|
}
|
||||||
(Arc::new(launch), agent, observed)
|
(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
|
/// **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
|
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
|
||||||
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
|
/// 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)),
|
structured_opencode_profile(pid(9)),
|
||||||
factory.clone(),
|
factory.clone(),
|
||||||
Arc::clone(&provider),
|
Arc::clone(&provider),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let (launch_b, agent_b, _) =
|
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));
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
store_a.seed_sync(pair, "opencode", "engine-project-a");
|
store_a.seed_sync(pair, "opencode", "engine-project-a");
|
||||||
store_b.seed_sync(pair, "opencode", "engine-project-b");
|
store_b.seed_sync(pair, "opencode", "engine-project-b");
|
||||||
|
|||||||
@ -325,6 +325,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
requester: Option<&str>,
|
requester: Option<&str>,
|
||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
sandbox: Option<&domain::SandboxPlan>,
|
sandbox: Option<&domain::SandboxPlan>,
|
||||||
|
|||||||
@ -1709,6 +1709,9 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
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>,
|
requester: Option<&str>,
|
||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||||
|
|||||||
@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
@ -405,6 +406,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
|||||||
&cwd,
|
&cwd,
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -25,7 +25,10 @@ use domain::ports::{
|
|||||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
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
|
/// 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**
|
/// un `session_id` capté (init/result). Permet à [`parse_event`] de rester **pure**
|
||||||
@ -300,6 +303,8 @@ pub struct ClaudeSdkSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Portable handoff injected only if the seeded provider session is stale.
|
||||||
|
resume_fallback_preamble: Option<String>,
|
||||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
sandbox: Option<SandboxPlan>,
|
sandbox: Option<SandboxPlan>,
|
||||||
@ -330,12 +335,20 @@ impl ClaudeSdkSession {
|
|||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
resume_fallback_preamble: None,
|
||||||
sandbox,
|
sandbox,
|
||||||
sandbox_enforcer,
|
sandbox_enforcer,
|
||||||
turn_cancel: TurnCancel::new(),
|
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.
|
/// Compose la ligne de commande d'un tour selon l'état de conversation.
|
||||||
///
|
///
|
||||||
/// Format RÉEL vérifié 2026-06-09 :
|
/// Format RÉEL vérifié 2026-06-09 :
|
||||||
@ -408,6 +421,7 @@ impl ClaudeSdkSession {
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
|
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
|
||||||
) -> Result<ReplyStream, AgentSessionError> {
|
) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
let had_native_resume = self.conversation_id().is_some();
|
||||||
let spec = self.build_spawn_line(prompt);
|
let spec = self.build_spawn_line(prompt);
|
||||||
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
|
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
|
||||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
|
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
|
||||||
@ -424,14 +438,27 @@ impl ClaudeSdkSession {
|
|||||||
});
|
});
|
||||||
(Some(line_tx), Some(parser))
|
(Some(line_tx), Some(parser))
|
||||||
});
|
});
|
||||||
let raw_result = run_turn_cancellable(
|
let mut raw_result = run_turn_cancellable(
|
||||||
&spec,
|
&spec,
|
||||||
None,
|
None,
|
||||||
self.sandbox_enforcer.as_ref(),
|
self.sandbox_enforcer.as_ref(),
|
||||||
line_tap,
|
line_tap.clone(),
|
||||||
&self.turn_cancel,
|
&self.turn_cancel,
|
||||||
)
|
)
|
||||||
.await;
|
.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 {
|
if let Some(parser) = parser_thread {
|
||||||
let _ = parser.join();
|
let _ = parser.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,10 @@ use domain::ports::{
|
|||||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
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
|
/// 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
|
/// id de conversation Codex capté. Miroir de `claude::ParsedLine` (vecteur d'events
|
||||||
@ -401,6 +404,8 @@ pub struct CodexExecSession {
|
|||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Portable handoff injected only if the seeded provider session is stale.
|
||||||
|
resume_fallback_preamble: Option<String>,
|
||||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
sandbox: Option<SandboxPlan>,
|
sandbox: Option<SandboxPlan>,
|
||||||
@ -503,12 +508,20 @@ impl CodexExecSession {
|
|||||||
model_reasoning_effort,
|
model_reasoning_effort,
|
||||||
env,
|
env,
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
resume_fallback_preamble: None,
|
||||||
sandbox,
|
sandbox,
|
||||||
sandbox_enforcer,
|
sandbox_enforcer,
|
||||||
turn_cancel: TurnCancel::new(),
|
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.
|
/// Compose la ligne de commande d'un tour.
|
||||||
///
|
///
|
||||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||||
@ -641,6 +654,7 @@ impl CodexExecSession {
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
|
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
|
||||||
) -> Result<ReplyStream, AgentSessionError> {
|
) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
let had_native_resume = self.conversation_id().is_some();
|
||||||
let spec = self.build_spawn_line(prompt);
|
let spec = self.build_spawn_line(prompt);
|
||||||
let has_tap = tap.is_some();
|
let has_tap = tap.is_some();
|
||||||
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
|
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
|
||||||
@ -670,14 +684,27 @@ impl CodexExecSession {
|
|||||||
});
|
});
|
||||||
(Some(line_tx), Some(parser))
|
(Some(line_tx), Some(parser))
|
||||||
});
|
});
|
||||||
let raw_result = run_turn_cancellable(
|
let mut raw_result = run_turn_cancellable(
|
||||||
&spec,
|
&spec,
|
||||||
None,
|
None,
|
||||||
self.sandbox_enforcer.as_ref(),
|
self.sandbox_enforcer.as_ref(),
|
||||||
line_tap,
|
line_tap.clone(),
|
||||||
&self.turn_cancel,
|
&self.turn_cancel,
|
||||||
)
|
)
|
||||||
.await;
|
.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 {
|
if let Some(parser) = parser_thread {
|
||||||
let _ = parser.join();
|
let _ = parser.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
resume_fallback_preamble: Option<&str>,
|
||||||
requester: Option<&str>,
|
requester: Option<&str>,
|
||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
@ -175,9 +176,10 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||||
let session: Arc<dyn AgentSession> = match adapter {
|
let session: Arc<dyn AgentSession> = match adapter {
|
||||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
StructuredAdapter::Claude => Arc::new(
|
||||||
id, command, cwd, seed, plan, enforcer,
|
ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer)
|
||||||
)),
|
.with_resume_fallback_preamble(resume_fallback_preamble),
|
||||||
|
),
|
||||||
StructuredAdapter::Codex => {
|
StructuredAdapter::Codex => {
|
||||||
let policy = match structured_policy {
|
let policy = match structured_policy {
|
||||||
Some(StructuredProviderLaunchPolicy::Codex {
|
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,
|
id,
|
||||||
command,
|
profile.command.clone(),
|
||||||
cwd,
|
profile.args.clone(),
|
||||||
|
cwd.clone(),
|
||||||
|
ctx.project_root.clone(),
|
||||||
seed,
|
seed,
|
||||||
policy.0,
|
|
||||||
policy.1,
|
|
||||||
policy.2,
|
|
||||||
profile.model.clone(),
|
|
||||||
profile.model_reasoning_effort.clone(),
|
|
||||||
env.to_vec(),
|
env.to_vec(),
|
||||||
plan,
|
plan,
|
||||||
enforcer,
|
enforcer,
|
||||||
))
|
)?
|
||||||
}
|
.with_resume_fallback_preamble(resume_fallback_preamble),
|
||||||
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,
|
|
||||||
)?),
|
|
||||||
StructuredAdapter::OpenAiCompatible => {
|
StructuredAdapter::OpenAiCompatible => {
|
||||||
let config = profile.chat_http.clone().ok_or_else(|| {
|
let config = profile.chat_http.clone().ok_or_else(|| {
|
||||||
AgentSessionError::Start(format!(
|
AgentSessionError::Start(format!(
|
||||||
|
|||||||
@ -577,6 +577,7 @@ mod tests {
|
|||||||
&cwd(),
|
&cwd(),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -601,6 +602,7 @@ mod tests {
|
|||||||
&cwd(),
|
&cwd(),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
Some(&codex_policy),
|
Some(&codex_policy),
|
||||||
@ -634,6 +636,7 @@ mod tests {
|
|||||||
&temp_cwd("factory-openai"),
|
&temp_cwd("factory-openai"),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -672,6 +675,7 @@ mod tests {
|
|||||||
&cwd(),
|
&cwd(),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -723,6 +727,7 @@ mod tests {
|
|||||||
&cwd(),
|
&cwd(),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
Some(&policy),
|
Some(&policy),
|
||||||
@ -762,6 +767,7 @@ mod tests {
|
|||||||
conversation_id: "repris-42".to_owned(),
|
conversation_id: "repris-42".to_owned(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -916,6 +922,89 @@ mod tests {
|
|||||||
(bin.to_string_lossy().into_owned(), argv)
|
(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 ---------------
|
// ---- Claude parse_event : plusieurs blocs, robustesse ---------------
|
||||||
|
|
||||||
/// Messages successifs : chaque message produit ses événements ; ici un par
|
/// Messages successifs : chaque message produit ses événements ; ici un par
|
||||||
@ -1848,6 +1937,7 @@ mod tests {
|
|||||||
conversation_id: "cx-resume".to_owned(),
|
conversation_id: "cx-resume".to_owned(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
Some(&policy),
|
Some(&policy),
|
||||||
@ -1874,6 +1964,7 @@ mod tests {
|
|||||||
conversation_id: "ignored-by-engine".to_owned(),
|
conversation_id: "ignored-by-engine".to_owned(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@ -1907,6 +1998,7 @@ mod tests {
|
|||||||
&cwd(),
|
&cwd(),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ use domain::ports::{
|
|||||||
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
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.
|
/// Un événement OpenCode parsé depuis stdout JSONL.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@ -555,6 +555,8 @@ pub struct OpenCodeSession {
|
|||||||
project_root: String,
|
project_root: String,
|
||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
engine_session_id: Mutex<Option<String>>,
|
engine_session_id: Mutex<Option<String>>,
|
||||||
|
/// Portable handoff injected only if the seeded provider session is stale.
|
||||||
|
resume_fallback_preamble: Option<String>,
|
||||||
turn_cancel: TurnCancel,
|
turn_cancel: TurnCancel,
|
||||||
sandbox: Option<SandboxPlan>,
|
sandbox: Option<SandboxPlan>,
|
||||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
@ -585,12 +587,20 @@ impl OpenCodeSession {
|
|||||||
project_root: project_root.into(),
|
project_root: project_root.into(),
|
||||||
env,
|
env,
|
||||||
engine_session_id: Mutex::new(seed),
|
engine_session_id: Mutex::new(seed),
|
||||||
|
resume_fallback_preamble: None,
|
||||||
turn_cancel: TurnCancel::new(),
|
turn_cancel: TurnCancel::new(),
|
||||||
sandbox,
|
sandbox,
|
||||||
sandbox_enforcer,
|
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<String> {
|
fn build_args(&self, prompt: &str, engine_session_id: Option<&str>) -> Vec<String> {
|
||||||
let mut args = self.prefix_args.clone();
|
let mut args = self.prefix_args.clone();
|
||||||
args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]);
|
args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]);
|
||||||
@ -663,9 +673,16 @@ impl OpenCodeSession {
|
|||||||
Ok(events) => events,
|
Ok(events) => events,
|
||||||
Err(err)
|
Err(err)
|
||||||
if initial_session.is_some()
|
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),
|
Err(err) => return Err(err),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -722,11 +722,59 @@ fn process_exit_message(command: &str, code: Option<i32>, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Instant;
|
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]
|
#[tokio::test]
|
||||||
async fn run_turn_cancellable_kills_current_child_without_waiting_for_natural_exit() {
|
async fn run_turn_cancellable_kills_current_child_without_waiting_for_natural_exit() {
|
||||||
let spec = SpawnLine {
|
let spec = SpawnLine {
|
||||||
|
|||||||
@ -476,6 +476,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
|||||||
&cwd,
|
&cwd,
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
Some(&plan),
|
Some(&plan),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -496,6 +496,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_resume_fallback_preamble: Option<&str>,
|
||||||
_requester: Option<&str>,
|
_requester: Option<&str>,
|
||||||
_env: &[(String, String)],
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
|
|||||||
@ -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(
|
||||||
|
<DIProvider gateways={{
|
||||||
|
agent,
|
||||||
|
conversation,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}>
|
||||||
|
<CustomAgentChatView
|
||||||
|
freshOnOpen
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="stale-session"
|
||||||
|
conversationId="stale-conversation"
|
||||||
|
onSessionId={onSessionId}
|
||||||
|
onConversationId={onConversationId}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {
|
||||||
let finishSend: (() => void) | undefined;
|
let finishSend: (() => void) | undefined;
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -2238,6 +2238,20 @@ export function CustomAgentChatView({
|
|||||||
setExternalSessionId(null);
|
setExternalSessionId(null);
|
||||||
setLiveSessionAttached(false);
|
setLiveSessionAttached(false);
|
||||||
publishSessionId(null);
|
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", {
|
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -2397,6 +2411,7 @@ export function CustomAgentChatView({
|
|||||||
reattachStructuredSession,
|
reattachStructuredSession,
|
||||||
recoverStructuredSession,
|
recoverStructuredSession,
|
||||||
publishSessionId,
|
publishSessionId,
|
||||||
|
publishConversationId,
|
||||||
applyChatViewState,
|
applyChatViewState,
|
||||||
conversation,
|
conversation,
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
Reference in New Issue
Block a user