merge feature/117-opencode-headless-recovery dans develop

Feature terminée et verte (Architect/DevBackend/DevFrontend/QA au vert) :
durcissement de la reprise headless inter-projets et persistance des
profils Opencode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 14:13:04 +02:00
15 changed files with 782 additions and 49 deletions

View File

@ -21,7 +21,6 @@
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
@ -31,7 +30,8 @@
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
"idea_template_delete",
"idea_create_skill"
]
},
"agents": [

View File

@ -73,3 +73,4 @@
- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping
- [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude.
- [tickets-70-100-102-ux-surface-scoping](tickets-70-100-102-ux-surface-scoping.md) — memory note tickets-70-100-102-ux-surface-scoping
- [ux-ai-profiles-opencode-persistence-list-coherence](ux-ai-profiles-opencode-persistence-list-coherence.md) — memory note ux-ai-profiles-opencode-persistence-list-coherence

View File

@ -0,0 +1,41 @@
---
name: ux-ai-profiles-opencode-persistence-list-coherence
description: memory note ux-ai-profiles-opencode-persistence-list-coherence
metadata:
type: project
---
---
title: UX — Cohérence profils IA/OpenCode entre éditeur et picker agents
type: decision
description: Décisions de surface pour corriger la création de profils OpenCode, la persistance visible après sauvegarde et la cohérence entre Settings > Profils IA et les sélecteurs d'agents.
---
# UX — Profils IA/OpenCode : création, persistance, liste unique
## Décision centrale
La liste visible dans `Settings > Profils IA` et les pickers de profils des agents doit représenter la même collection persistée de profils IA. Les profils de référence ne sont qu'une source d'ajout, pas une deuxième vérité visible.
## Comportement attendu
- Le bouton de création OpenCode s'appelle `Créer un profil OpenCode` dans la surface française.
- Cliquer sur ce bouton ajoute immédiatement une ligne de profil en brouillon, sélectionnée, éditable et clairement marquée non enregistrée tant que la sauvegarde n'a pas réussi.
- Chaque nouveau profil OpenCode doit avoir une identité distincte et un nom humain distinct par défaut, par exemple `OpenCode local 2`; l'utilisateur peut renommer avant sauvegarde.
- `Dupliquer` conserve la configuration du profil source mais crée une nouvelle identité et un nom suffixé, par exemple `(copie)`.
- `Enregistrer` persiste tous les profils sélectionnés/édités, puis recharge la liste depuis la source persistée. La liste affichée après succès doit être le résultat relu, pas seulement l'état local optimiste.
- Après sauvegarde réussie, le profil créé reste visible dans l'éditeur sans réouverture manuelle et apparaît dans le picker de création d'agent et dans le picker de changement de profil d'un agent existant.
- Les profils OpenCode ne doivent jamais être fusionnés/dédupliqués par commande, modèle, endpoint ou adapter. La déduplication visible se fait uniquement par `id`.
- En mode édition, les profils déjà configurés apparaissent en premier, pré-sélectionnés, avec leur configuration réelle intacte. Les profils de référence non configurés peuvent suivre comme suggestions non sélectionnées.
- Si un profil référencé par un agent n'existe plus dans la liste persistée, le picker de l'agent conserve l'id courant comme option orpheline lisible, sans le mélanger aux profils disponibles.
## États et feedback
- Pendant la création/clonage : bouton désactivé ou loading local, sans bloquer la détection CLI.
- Ligne brouillon : indicateur discret `Non enregistré` ou état visuel équivalent.
- Sauvegarde réussie : statut court `Profils enregistrés` puis disparition automatique.
- Échec sauvegarde : message d'erreur persistant, la ligne brouillon reste éditable et n'est pas perdue.
- Picker agents vide : ne pas encourager la saisie libre d'id comme chemin normal. Afficher plutôt un état vide actionnable vers `Profils IA`.
## Critères d'acceptation visuels
- Créer un profil OpenCode, sauvegarder, rester sur Settings : le profil est toujours visible après le retour succès.
- Sans fermer Settings, ouvrir/créer un agent : le même profil est disponible dans le picker.
- Deux profils OpenCode avec même endpoint/modèle mais ids distincts restent deux lignes et deux options distinctes.
- Renommer un profil puis sauvegarder met à jour le libellé dans l'éditeur et dans les pickers agents.
- Aucun écran ne montre simultanément une liste issue seulement des références et une liste issue des profils persistés comme si elles étaient équivalentes.

View File

@ -72,8 +72,18 @@ impl TicketToolProvider for AppTicketToolProvider {
.await
.map_err(ticket_error)?
.issue;
let fallback_carnet = issue.carnet.as_str().to_owned();
let carnet = if include_carnet {
Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?)
Some(
read_ticket_carnet_body_best_effort(
&*self.read_carnet,
project,
issue_ref,
fallback_carnet,
)
.await
.map_err(ticket_error)?,
)
} else {
None
};
@ -287,17 +297,17 @@ pub async fn ticket_read(
.await
.map_err(ErrorDto::from)?
.issue;
let fallback_carnet = issue.carnet.as_str().to_owned();
let carnet = if request.include_carnet.unwrap_or(false) {
Some(
state
.read_issue_carnet
.execute(ReadIssueCarnetInput { project, issue_ref })
read_ticket_carnet_body_best_effort(
&state.read_issue_carnet,
&project,
issue_ref,
fallback_carnet,
)
.await
.map_err(ErrorDto::from)?
.carnet
.carnet
.as_str()
.to_owned(),
.map_err(ErrorDto::from)?,
)
} else {
None
@ -680,6 +690,25 @@ async fn read_carnet_body(
.map_err(ticket_error)
}
async fn read_ticket_carnet_body_best_effort(
read_carnet: &application::ReadIssueCarnet,
project: &Project,
issue_ref: IssueRef,
fallback_carnet: String,
) -> Result<String, AppError> {
match read_carnet
.execute(ReadIssueCarnetInput {
project: project.clone(),
issue_ref,
})
.await
{
Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()),
Err(AppError::NotFound(_)) => Ok(fallback_carnet),
Err(err) => Err(err),
}
}
fn ticket_error(err: AppError) -> TicketToolError {
let message = err.to_string();
let code = if message.contains("issue version conflict") {

View File

@ -0,0 +1,98 @@
use std::path::PathBuf;
use app_tauri_lib::state::AppState;
use application::CreateProjectInput;
use infrastructure::TicketToolProvider;
use serde_json::json;
use uuid::Uuid;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("idea-app-tauri-test-{tag}-{}", Uuid::new_v4()))
}
#[tokio::test]
async fn idea_ticket_read_is_best_effort_but_read_carnet_stays_strict_when_carnet_file_is_missing()
{
let state = AppState::build(temp_path("appdata"));
let project_root = temp_path("project").to_string_lossy().into_owned();
let project = state
.create_project
.execute(CreateProjectInput {
name: "App Tauri Tickets".to_owned(),
root: project_root,
remote: None,
default_profile_id: None,
})
.await
.expect("test project is created")
.project;
let created = state
.ticket_tool_binder
.handle_ticket_tool(
&project,
"qa-agent",
"idea_ticket_create",
json!({
"title": "Missing carnet fallback",
"description": "Created by app-tauri integration test",
"priority": "high",
"status": "open"
}),
)
.await
.expect("ticket create succeeds");
let ticket_ref = created["ref"]
.as_str()
.expect("ticket ref is present")
.to_owned();
let read = state
.ticket_tool_binder
.handle_ticket_tool(
&project,
"qa-agent",
"idea_ticket_read",
json!({ "ref": ticket_ref, "includeCarnet": true }),
)
.await
.expect("ticket read succeeds");
assert!(
read["carnet"].is_string(),
"ticket read returns a string carnet"
);
std::fs::remove_file(
PathBuf::from(project.root.as_str())
.join(".ideai")
.join("tickets")
.join(ticket_ref.trim_start_matches('#'))
.join("carnet.md"),
)
.expect("test removes ticket carnet");
let read_without_carnet_file = state
.ticket_tool_binder
.handle_ticket_tool(
&project,
"qa-agent",
"idea_ticket_read",
json!({ "ref": ticket_ref, "includeCarnet": true }),
)
.await
.expect("ticket read stays best-effort");
assert_eq!(read_without_carnet_file["ref"], ticket_ref);
assert_eq!(read_without_carnet_file["carnet"], "");
let strict_err = state
.ticket_tool_binder
.handle_ticket_tool(
&project,
"qa-agent",
"idea_ticket_read_carnet",
json!({ "ref": ticket_ref }),
)
.await
.expect_err("strict carnet read must fail when carnet.md is missing");
assert_eq!(strict_err.code, "notFound");
}

View File

@ -2025,7 +2025,11 @@ impl LaunchAgent {
// même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
// erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
// par cette persistance (lot P8c lira ce store pour `--resume`).
if let Some(engine_id) = engine_session_id.as_deref() {
if let Some(engine_id) = engine_session_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
{
self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
.await;
}
@ -2127,11 +2131,20 @@ impl LaunchAgent {
// L'id de paire (UUID) → resumable moteur via providers.json.
Some(raw) => match uuid::Uuid::parse_str(raw) {
Ok(uuid) => match provider.provider_session_store_for(root) {
Some(store) => store
Some(store) => match store
.get(ConversationId::from_uuid(uuid), adapter.provider_key())
.await
.ok()
.flatten(),
{
Ok(Some(engine_id)) => {
let engine_id = engine_id.trim().to_owned();
if engine_id.is_empty() {
None
} else {
Some(engine_id)
}
}
Ok(None) | Err(_) => None,
},
None => None,
},
Err(_) => None,

View File

@ -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"
);
}

View File

@ -62,8 +62,11 @@ type ProviderMap = HashMap<String, String>;
/// le **project root** est fourni au constructeur, la base `<root>/.ideai/conversations`
/// en dérive, et chaque conversation a son sous-dossier `<conversationId>/`.
pub struct FsProviderSessionStore {
/// Racine projet canonique. `None` si le root fourni n'est pas résoluble :
/// le store devient alors fail-closed (lecture `None`, écriture `Err`).
root: Option<PathBuf>,
/// Racine `<project_root>/.ideai/conversations`.
base: PathBuf,
base: Option<PathBuf>,
/// Verrous d'écriture, un par fichier de conversation (sérialise le read-modify-write).
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
}
@ -75,28 +78,48 @@ impl FsProviderSessionStore {
/// est créé paresseusement au premier `set`.
#[must_use]
pub fn new(root: &ProjectPath) -> Self {
let base = PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(CONVERSATIONS_DIR);
let root = std::fs::canonicalize(root.as_str()).ok();
let base = root
.as_ref()
.map(|root| root.join(IDEAI_DIR).join(CONVERSATIONS_DIR));
Self {
root,
base,
write_locks: Mutex::new(HashMap::new()),
}
}
/// `<base>/<conversationId>` — le dossier d'une conversation.
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
self.base.join(conversation.to_string())
fn conversation_dir(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
let Some(root) = self.root.as_ref() else {
return Err(StoreError::Io(
"project root canonique indisponible pour providers.json".to_owned(),
));
};
let Some(base) = self.base.as_ref() else {
return Err(StoreError::Io(
"base providers.json indisponible".to_owned(),
));
};
let dir = base.join(conversation.to_string());
if !dir.starts_with(root) {
return Err(StoreError::Io(
"chemin providers.json hors du project root canonique".to_owned(),
));
}
Ok(dir)
}
/// `<base>/<conversationId>/providers.json` — le fichier des sessions par provider.
fn providers_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(PROVIDERS_FILE)
fn providers_path(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
Ok(self.conversation_dir(conversation)?.join(PROVIDERS_FILE))
}
/// `<base>/<conversationId>/providers.json.tmp` — le fichier temporaire d'écriture.
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join(PROVIDERS_TMP_FILE)
fn providers_tmp_path(&self, conversation: ConversationId) -> Result<PathBuf, StoreError> {
Ok(self
.conversation_dir(conversation)?
.join(PROVIDERS_TMP_FILE))
}
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
@ -114,7 +137,11 @@ impl FsProviderSessionStore {
/// Fichier absent ⇒ map vide (jamais une erreur). JSON illisible ⇒
/// [`StoreError::Serialization`].
async fn load_map(&self, conversation: ConversationId) -> Result<ProviderMap, StoreError> {
let bytes = match tokio::fs::read(self.providers_path(conversation)).await {
let path = match self.providers_path(conversation) {
Ok(path) => path,
Err(_) => return Ok(ProviderMap::new()),
};
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProviderMap::new()),
Err(e) => return Err(StoreError::Io(e.to_string())),
@ -155,18 +182,18 @@ impl ProviderSessionStore for FsProviderSessionStore {
let body =
serde_json::to_vec(&map).map_err(|e| StoreError::Serialization(e.to_string()))?;
let dir = self.conversation_dir(conversation);
let dir = self.conversation_dir(conversation)?;
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne voit
// jamais de fichier à moitié écrit (le rename est atomique sur le FS).
let tmp = self.providers_tmp_path(conversation);
let tmp = self.providers_tmp_path(conversation)?;
tokio::fs::write(&tmp, &body)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
tokio::fs::rename(&tmp, self.providers_path(conversation))
tokio::fs::rename(&tmp, self.providers_path(conversation)?)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
Ok(())

View File

@ -207,6 +207,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
profile.command.clone(),
profile.args.clone(),
cwd,
seed,
env.to_vec(),
plan,
enforcer,

View File

@ -7,6 +7,7 @@
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
use async_trait::async_trait;
use serde_json::Value;
@ -32,6 +33,24 @@ pub enum ParsedEvent {
Ignored,
}
/// Identifiant natif de session OpenCode exposé dans les événements JSONL.
pub fn extract_session_id(line: &str) -> Result<Option<String>, AgentSessionError> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
Ok(value
.get("sessionID")
.or_else(|| value.get("session_id"))
.or_else(|| value.get("sessionId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned))
}
/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable";
@ -190,6 +209,7 @@ pub struct OpenCodeSession {
prefix_args: Vec<String>,
cwd: String,
env: Vec<(String, String)>,
engine_session_id: Mutex<Option<String>>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
@ -202,6 +222,7 @@ impl OpenCodeSession {
command_prefix: impl Into<String>,
profile_args: Vec<String>,
cwd: impl Into<String>,
seed: Option<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
@ -215,6 +236,7 @@ impl OpenCodeSession {
prefix_args: prefix,
cwd: cwd.into(),
env,
engine_session_id: Mutex::new(seed),
sandbox,
sandbox_enforcer,
})
@ -222,14 +244,20 @@ impl OpenCodeSession {
fn build_args(&self, prompt: &str) -> Vec<String> {
let mut args = self.prefix_args.clone();
args.extend([
"run".to_owned(),
"--format".to_owned(),
"json".to_owned(),
prompt.to_owned(),
]);
args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]);
if let Some(engine_id) = self.conversation_id() {
args.extend(["--session".to_owned(), engine_id]);
}
args.push(prompt.to_owned());
args
}
fn capture_session_id(&self, engine_id: String) {
*self
.engine_session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(engine_id);
}
}
#[async_trait]
@ -239,7 +267,10 @@ impl AgentSession for OpenCodeSession {
}
fn conversation_id(&self) -> Option<String> {
None
self.engine_session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
@ -277,6 +308,9 @@ impl AgentSession for OpenCodeSession {
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
{
if let Some(engine_id) = extract_session_id(&line)? {
self.capture_session_id(engine_id);
}
collected.push(line);
}
@ -512,6 +546,7 @@ exit 1
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
None,
Vec::new(),
None,
None,
@ -534,4 +569,109 @@ exit 1
]
);
}
#[test]
fn extract_session_id_accepts_opencode_jsonl_session_id_variants() {
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionID":"s1"}"#).unwrap(),
Some("s1".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","session_id":"s2"}"#).unwrap(),
Some("s2".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionId":"s3"}"#).unwrap(),
Some("s3".to_owned())
);
assert_eq!(
extract_session_id(r#"{"type":"step_start","sessionID":" "}"#).unwrap(),
None
);
}
#[cfg(unix)]
#[tokio::test]
async fn send_captures_engine_session_id_from_jsonl() {
let tmp = TempDir::new("capture-session");
let script = tmp.path().join("opencode-fixture.sh");
fs::write(
&script,
r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}'
printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
"#,
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
None,
Vec::new(),
None,
None,
)
.unwrap();
assert_eq!(session.conversation_id(), None);
let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();
assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
assert_eq!(
session.conversation_id().as_deref(),
Some("opencode-engine-1")
);
}
#[cfg(unix)]
#[tokio::test]
async fn seeded_or_captured_session_id_is_passed_as_opencode_session_arg() {
let tmp = TempDir::new("resume-arg");
let script = tmp.path().join("opencode-fixture.sh");
let args_file = tmp.path().join("args.txt");
fs::write(
&script,
format!(
r#"#!/bin/sh
printf '%s\n' "$@" > '{}'
printf '%s\n' '{{"type":"step_start","sessionID":"seeded-engine"}}'
printf '%s\n' '{{"type":"text","sessionID":"seeded-engine","part":{{"text":"ok"}}}}'
printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
"#,
args_file.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()),
Vec::new(),
None,
None,
)
.unwrap();
session.send("prompt").await.unwrap().for_each(drop);
let args = fs::read_to_string(args_file).unwrap();
let argv: Vec<&str> = args.lines().collect();
assert!(
argv.windows(2)
.any(|pair| pair == ["--session", "seeded-engine"]),
"OpenCode resume must use `--session <engine id>`, got: {argv:?}"
);
assert_eq!(argv.last(), Some(&"prompt"));
}
}

View File

@ -1294,6 +1294,64 @@ async fn provider_is_isolated_per_conversation() {
);
}
#[tokio::test]
async fn provider_is_isolated_per_project_root_with_same_conversation_and_provider() {
let project_a = TempDir::new();
let project_b = TempDir::new();
let store_a = FsProviderSessionStore::new(&project_a.project_path());
let store_b = FsProviderSessionStore::new(&project_b.project_path());
let c = conv_id(77);
store_a.set(c, "opencode", "engine-a").await.unwrap();
store_b.set(c, "opencode", "engine-b").await.unwrap();
assert_eq!(
store_a.get(c, "opencode").await.unwrap(),
Some("engine-a".to_owned())
);
assert_eq!(
store_b.get(c, "opencode").await.unwrap(),
Some("engine-b".to_owned())
);
assert!(
!std::fs::read_to_string(project_a.providers_path(c))
.unwrap()
.contains("engine-b"),
"project A providers.json must not contain project B's engine id"
);
assert!(
!std::fs::read_to_string(project_b.providers_path(c))
.unwrap()
.contains("engine-a"),
"project B providers.json must not contain project A's engine id"
);
}
#[tokio::test]
async fn provider_unresolved_project_root_fails_closed() {
let root = ProjectPath::new(format!(
"{}/missing-{}",
std::env::temp_dir().to_string_lossy(),
Uuid::new_v4()
))
.unwrap();
let store = FsProviderSessionStore::new(&root);
let c = conv_id(78);
assert_eq!(
store.get(c, "opencode").await.unwrap(),
None,
"unresolved root cannot produce a resumable id"
);
assert!(
matches!(
store.set(c, "opencode", "engine").await,
Err(StoreError::Io(_))
),
"unresolved root must not write outside a canonical project root"
);
}
// ===========================================================================
// LS6 — rotation (ConversationArchive) + pagination humaine, I/O réelle.
// Réutilise le scaffolding L2 (TempDir absolu, constructeurs déterministes).

View File

@ -34,7 +34,7 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use application::{
AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput,
AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput,
AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput,
CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput,
@ -2983,17 +2983,17 @@ async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result<Value,
.await
.map_err(ErrorDto::from)?
.issue;
let fallback_carnet = issue.carnet.as_str().to_owned();
let carnet = if request.include_carnet.unwrap_or(false) {
Some(
state
.read_issue_carnet
.execute(ReadIssueCarnetInput { project, issue_ref })
read_ticket_carnet_body_best_effort(
&state.read_issue_carnet,
&project,
issue_ref,
fallback_carnet,
)
.await
.map_err(ErrorDto::from)?
.carnet
.carnet
.as_str()
.to_owned(),
.map_err(ErrorDto::from)?,
)
} else {
None
@ -3001,6 +3001,25 @@ async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result<Value,
serde_json::to_value(TicketDto::from_issue(issue, carnet)).map_err(serialization_error)
}
async fn read_ticket_carnet_body_best_effort(
read_carnet: &application::ReadIssueCarnet,
project: &Project,
issue_ref: domain::IssueRef,
fallback_carnet: String,
) -> Result<String, AppError> {
match read_carnet
.execute(ReadIssueCarnetInput {
project: project.clone(),
issue_ref,
})
.await
{
Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()),
Err(AppError::NotFound(_)) => Ok(fallback_carnet),
Err(err) => Err(err),
}
}
async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketListRequestDto>("ticket_list", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
@ -7902,6 +7921,34 @@ mod tests {
assert_eq!(read["ref"], ticket_ref);
assert!(read["carnet"].is_string());
let project = state
.app
.open_project
.execute(OpenProjectInput {
project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id).unwrap()),
})
.await
.expect("test project is readable")
.project;
std::fs::remove_file(
std::path::PathBuf::from(project.root.as_str())
.join(".ideai")
.join("tickets")
.join(ticket_ref.trim_start_matches('#'))
.join("carnet.md"),
)
.expect("test removes ticket carnet");
let (status, read_without_carnet_file) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read",
json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read_without_carnet_file["ref"], ticket_ref);
assert_eq!(read_without_carnet_file["carnet"], "");
let (status, list) = invoke_request_for_test(
Arc::clone(&state),
&cookie,

View File

@ -364,6 +364,21 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
const options = Array.from(select.options).map((o) => o.text);
expect(options).toContain("Claude Code");
});
it("shows persisted OpenCode profiles in the agent profile dropdown", async () => {
const profile = new MockProfileGateway();
const openCode = await profile.cloneOpenCodeProfileFromSeed({
name: "OpenCode Local Durable",
});
await profile.saveProfile(openCode);
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.text);
expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true);
});
});
// ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
@ -221,4 +221,70 @@ describe("ProfilesSettings", () => {
),
).toBeTruthy();
});
it("creates OpenCode profiles through the OpenCode clone flow and persists them for agent pickers", async () => {
const profile = new MockProfileGateway();
const cloneOpenCodeSpy = vi.spyOn(profile, "cloneOpenCodeProfileFromSeed");
const cloneGenericSpy = vi.spyOn(profile, "cloneProfileFromSeed");
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy");
expect(name).toBeTruthy();
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy");
expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true);
});
expect(cloneOpenCodeSpy).toHaveBeenCalledWith({
name: "OpenCode + llama.cpp copy",
opencode: expect.objectContaining({
baseURL: "http://localhost:8080/v1",
model: "qwen3-coder-30b",
}),
});
expect(cloneGenericSpy).not.toHaveBeenCalled();
});
it("duplicates OpenCode profiles through the OpenCode clone flow and preserves their local config", async () => {
const profile = new MockProfileGateway();
renderSettings(profile);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), {
target: { value: "local-special" },
});
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved[0]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
});
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", {
name: "Dupliquer",
}));
await waitFor(async () => {
const saved = await profile.listProfiles();
const copies = saved.filter((p) => p.structuredAdapter === "openCode");
expect(copies).toHaveLength(2);
expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy");
expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1");
expect(copies[1]?.opencode?.model).toBe("local-special");
});
});
});

View File

@ -56,7 +56,10 @@ function describe(e: unknown): string {
function tabFor(profile: AgentProfile): ProfileTab | null {
if (profile.structuredAdapter === "codex") return "codex";
if (profile.structuredAdapter === "claude") return "claude";
if (profile.structuredAdapter === "openCode" && profile.opencode) {
if (
profile.structuredAdapter === "openCode" &&
(profile.opencode || profile.opencodeProvider)
) {
return "openCode";
}
return null;
@ -352,6 +355,16 @@ export function ProfilesSettings() {
setBusy(true);
setError(null);
try {
if (activeTab === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${seed.name} copy`,
opencode: seed.opencode,
});
await profile.saveProfile(created);
await refresh();
return;
}
const models =
activeTab === "codex" || activeTab === "claude"
? catalogue[activeTab].models
@ -404,6 +417,16 @@ export function ProfilesSettings() {
setBusy(true);
setError(null);
try {
if (source.structuredAdapter === "openCode") {
const created = await profile.cloneOpenCodeProfileFromSeed({
name: `${source.name} copy`,
opencode: source.opencode,
});
await profile.saveProfile(created);
await refresh();
return;
}
await profile.cloneProfileFromSeed({
seedProfileId: source.id,
name: `${source.name} copy`,