chantier: durcissement reprise headless inter-projets + persistance profils Opencode
- Implémentation reprise de session Opencode headless (conversational_recovery) - Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema - Gestion des permissions MCP pour agents externes - Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings
This commit is contained in:
@ -566,6 +566,7 @@ use domain::{ConversationId, ConversationParty, ProviderSessionStore};
|
||||
struct FakeProviderSessionStore {
|
||||
entries: Arc<Mutex<HashMap<(ConversationId, String), String>>>,
|
||||
fail_set: bool,
|
||||
fail_get: bool,
|
||||
}
|
||||
|
||||
impl FakeProviderSessionStore {
|
||||
@ -577,6 +578,15 @@ impl FakeProviderSessionStore {
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
fail_set: true,
|
||||
fail_get: false,
|
||||
}
|
||||
}
|
||||
/// A store whose `get` always errors (corrupted/unreadable providers.json).
|
||||
fn failing_get() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
fail_set: false,
|
||||
fail_get: true,
|
||||
}
|
||||
}
|
||||
/// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
|
||||
@ -608,6 +618,11 @@ impl ProviderSessionStore for FakeProviderSessionStore {
|
||||
conversation: ConversationId,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<String>, StoreError> {
|
||||
if self.fail_get {
|
||||
return Err(StoreError::Serialization(
|
||||
"forced providers.json corruption".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(self.get_sync(conversation, provider_id))
|
||||
}
|
||||
async fn set(
|
||||
@ -641,6 +656,31 @@ impl ProviderSessionProvider for FakeProviderSessionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RootAwareProviderSessionProvider {
|
||||
stores: Arc<Mutex<HashMap<String, Arc<FakeProviderSessionStore>>>>,
|
||||
}
|
||||
|
||||
impl RootAwareProviderSessionProvider {
|
||||
fn insert(&self, root: &str, store: Arc<FakeProviderSessionStore>) {
|
||||
self.stores.lock().unwrap().insert(root.to_owned(), store);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderSessionProvider for RootAwareProviderSessionProvider {
|
||||
fn provider_session_store_for(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ProviderSessionStore>> {
|
||||
self.stores
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(root.as_str())
|
||||
.cloned()
|
||||
.map(|store| store as Arc<dyn ProviderSessionStore>)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -675,6 +715,17 @@ fn project() -> Project {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn project_with_root(root: &str) -> Project {
|
||||
Project::new(
|
||||
project_id(),
|
||||
"demo",
|
||||
ProjectPath::new(root).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md.
|
||||
fn structured_profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
@ -1398,6 +1449,22 @@ fn structured_codex_profile(id: ProfileId) -> AgentProfile {
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
}
|
||||
|
||||
/// Profil **structuré OpenCode** pour prouver la clé provider `"opencode"`.
|
||||
fn structured_opencode_profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"OpenCode Structuré",
|
||||
"opencode",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some("opencode --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
}
|
||||
|
||||
/// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)`
|
||||
/// over the given profile/factory, returning the launcher, the agent and the
|
||||
/// observable provider store. When `provider` is `None`, no provider is wired
|
||||
@ -1504,6 +1571,7 @@ async fn p8b_nominal_codex_writes_engine_id_under_codex_key() {
|
||||
fn p8b_provider_key_mapping_is_stable() {
|
||||
assert_eq!(StructuredAdapter::Claude.provider_key(), "claude");
|
||||
assert_eq!(StructuredAdapter::Codex.provider_key(), "codex");
|
||||
assert_eq!(StructuredAdapter::OpenCode.provider_key(), "opencode");
|
||||
}
|
||||
|
||||
/// **No-op sans provider** : sans `with_provider_session_provider`, le lancement
|
||||
@ -1615,6 +1683,39 @@ fn launch_fixture_p8c(
|
||||
(launch, agent, observed)
|
||||
}
|
||||
|
||||
fn launch_fixture_with_provider(
|
||||
profile: AgentProfile,
|
||||
factory: FakeFactory,
|
||||
provider: Arc<dyn ProviderSessionProvider>,
|
||||
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
||||
let observed = factory.clone();
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let runtime = FakeRuntime::new();
|
||||
let fs = FakeFs::default();
|
||||
let pty = FakePty::new(sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(runtime),
|
||||
Arc::new(fs),
|
||||
Arc::new(pty),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_structured(Arc::new(factory), Arc::clone(&structured))
|
||||
.with_provider_session_provider(provider);
|
||||
(Arc::new(launch), agent, observed)
|
||||
}
|
||||
|
||||
/// **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 —
|
||||
@ -1812,3 +1913,76 @@ async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
|
||||
"a non-UUID pair id must degrade to None, never resume the raw string"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn p8c_store_get_error_fails_closed_to_fresh_session() {
|
||||
let store = Arc::new(FakeProviderSessionStore::failing_get());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_opencode_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::None,
|
||||
"corrupted provider session store must start fresh, never resume ambiguously"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn p8c_opencode_same_pair_and_provider_are_isolated_by_project_root() {
|
||||
let store_a = Arc::new(FakeProviderSessionStore::new());
|
||||
let store_b = Arc::new(FakeProviderSessionStore::new());
|
||||
let provider = RootAwareProviderSessionProvider::default();
|
||||
provider.insert("/home/me/project-a", Arc::clone(&store_a));
|
||||
provider.insert("/home/me/project-b", Arc::clone(&store_b));
|
||||
let provider = Arc::new(provider) as Arc<dyn ProviderSessionProvider>;
|
||||
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch_a, agent_a, observed) = launch_fixture_with_provider(
|
||||
structured_opencode_profile(pid(9)),
|
||||
factory.clone(),
|
||||
Arc::clone(&provider),
|
||||
);
|
||||
let (launch_b, agent_b, _) =
|
||||
launch_fixture_with_provider(structured_opencode_profile(pid(9)), factory, provider);
|
||||
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");
|
||||
|
||||
let mut input_a = launch_input(agent_a.id);
|
||||
input_a.project = project_with_root("/home/me/project-a");
|
||||
input_a.conversation_id = Some(pair.to_string());
|
||||
launch_a.execute(input_a).await.expect("project A launch");
|
||||
|
||||
let mut input_b = launch_input(agent_b.id);
|
||||
input_b.project = project_with_root("/home/me/project-b");
|
||||
input_b.conversation_id = Some(pair.to_string());
|
||||
launch_b.execute(input_b).await.expect("project B launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 2);
|
||||
assert_eq!(
|
||||
starts[0].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-project-a".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
starts[1].1,
|
||||
SessionPlan::Resume {
|
||||
conversation_id: "engine-project-b".to_owned()
|
||||
},
|
||||
"project B must read its own opencode token, never project A's"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user