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:
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user