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:
2026-07-28 12:23:34 +02:00
parent 78f7c8fe2d
commit 045e0984cc
15 changed files with 782 additions and 49 deletions

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 })
.await
.map_err(ErrorDto::from)?
.carnet
.carnet
.as_str()
.to_owned(),
read_ticket_carnet_body_best_effort(
&state.read_issue_carnet,
&project,
issue_ref,
fallback_carnet,
)
.await
.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 })
.await
.map_err(ErrorDto::from)?
.carnet
.carnet
.as_str()
.to_owned(),
read_ticket_carnet_body_best_effort(
&state.read_issue_carnet,
&project,
issue_ref,
fallback_carnet,
)
.await
.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,