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

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