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:
@ -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") {
|
||||
|
||||
98
crates/app-tauri/tests/tickets_missing_carnet.rs
Normal file
98
crates/app-tauri/tests/tickets_missing_carnet.rs
Normal 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");
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -207,6 +207,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
profile.command.clone(),
|
||||
profile.args.clone(),
|
||||
cwd,
|
||||
seed,
|
||||
env.to_vec(),
|
||||
plan,
|
||||
enforcer,
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user