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:
@ -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<NodeId>,
|
||||
/// 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<String>,
|
||||
/// 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,
|
||||
|
||||
@ -179,6 +179,7 @@ impl OpenTicketAssistant {
|
||||
&prepared,
|
||||
&environment.cwd,
|
||||
&SessionPlan::None,
|
||||
None,
|
||||
Some(&requester),
|
||||
&environment.env,
|
||||
None,
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user