Corrige la perte de session à la navigation: le démontage du CustomAgentChatView appelait close_agent_session (destructeur) au lieu de simplement détacher le transport. Désormais: - nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le canal sans tuer la session ni le scrollback retenu; distinct de close_agent_session qui reste le close destructeur explicite; - le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un cache de session (turns + chunkCount) à la réattache; - message « Reprise effectuée: N événements rattrapés… » quand la réattache rejoue plus de chunks que le cache local n'en avait conservé. Enrichit le rendu tools/MCP/progress: - ProgressSettler (chat bridge) émet un progress completed synthétique quand un spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini les spinners fantômes; - les adapters claude/codex/opencode/openai_compat extraient un aperçu du payload (arguments/input/command/message/params) attaché au progress; - bounded_reply_progress_preview (domain) garde les 4 premières lignes non vides, normalisation CR/LF; - previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent affichés sous le titre; suppression du flag settled et du Spinner inline devenus redondants (l'état est porté par le stage + le settler). Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage reply_progress, item_preview/tool_preview par adapter, réattache avec cache et notice de rattrapage.
1375 lines
47 KiB
Rust
1375 lines
47 KiB
Rust
//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp.
|
|
//!
|
|
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
|
|
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
|
|
//! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat
|
|
//! JSONL observé/cadré : `step_start`, `text(part.text)`, `tool_use`,
|
|
//! `step_finish`, `error`.
|
|
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::Value;
|
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
use tokio::sync::Mutex as AsyncMutex;
|
|
|
|
use domain::ports::{
|
|
AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
|
|
ReplyProgressSource, ReplyProgressStage, ReplyStream,
|
|
};
|
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
|
use domain::SessionId;
|
|
|
|
/// Un événement OpenCode parsé depuis stdout JSONL.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParsedEvent {
|
|
/// Début d'étape OpenCode.
|
|
StepStart,
|
|
/// Fragment texte assistant.
|
|
Text(String),
|
|
/// Activité outil OpenCode.
|
|
ToolActivity {
|
|
/// Nom d'outil affichable.
|
|
label: String,
|
|
/// Aperçu borné du payload d'appel outil, quand OpenCode l'expose.
|
|
preview: Option<String>,
|
|
},
|
|
/// Fin d'étape OpenCode.
|
|
StepFinish,
|
|
/// Erreur terminale remontée par OpenCode.
|
|
Error(String),
|
|
/// Evénement JSON valide mais hors contrat minimal.
|
|
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(
|
|
json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
|
|
value
|
|
.get("part")
|
|
.and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// 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";
|
|
|
|
/// Parse une ligne JSONL OpenCode.
|
|
///
|
|
/// # Errors
|
|
/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide.
|
|
pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
|
|
parse_jsonl_record(line).map(|record| record.event)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ParsedRecord {
|
|
event: ParsedEvent,
|
|
session_id: Option<String>,
|
|
message_id: Option<String>,
|
|
}
|
|
|
|
fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(ParsedRecord {
|
|
event: ParsedEvent::Ignored,
|
|
session_id: None,
|
|
message_id: None,
|
|
});
|
|
}
|
|
let value: Value = serde_json::from_str(trimmed)
|
|
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
|
|
let part = value.get("part");
|
|
let session_id =
|
|
json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
|
|
part.and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
|
|
});
|
|
let message_id =
|
|
json_string_field(&value, &["messageID", "message_id", "messageId"]).or_else(|| {
|
|
part.and_then(|part| json_string_field(part, &["messageID", "message_id", "messageId"]))
|
|
});
|
|
let event = match value.get("type").and_then(Value::as_str).or_else(|| {
|
|
part.and_then(|part| part.get("type"))
|
|
.and_then(Value::as_str)
|
|
}) {
|
|
Some("step_start" | "step.start" | "step-start") => ParsedEvent::StepStart,
|
|
Some("step_finish" | "step.finish" | "step-finish") => ParsedEvent::StepFinish,
|
|
// Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ;
|
|
// un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer.
|
|
Some("text") => part
|
|
.and_then(|part| part.get("text"))
|
|
.or_else(|| value.get("text"))
|
|
.or_else(|| value.get("content"))
|
|
.and_then(Value::as_str)
|
|
.map(|text| ParsedEvent::Text(text.to_owned()))
|
|
.unwrap_or(ParsedEvent::Ignored),
|
|
Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity {
|
|
label: tool_label(&value),
|
|
preview: tool_preview(&value),
|
|
},
|
|
Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)),
|
|
_ => ParsedEvent::Ignored,
|
|
};
|
|
Ok(ParsedRecord {
|
|
event,
|
|
session_id,
|
|
message_id,
|
|
})
|
|
}
|
|
|
|
fn json_string_field(value: &Value, names: &[&str]) -> Option<String> {
|
|
names
|
|
.iter()
|
|
.find_map(|name| value.get(*name).and_then(Value::as_str))
|
|
.map(str::trim)
|
|
.filter(|id| !id.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
/// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une
|
|
/// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement).
|
|
fn opencode_error_message(value: &Value) -> String {
|
|
let error = value
|
|
.get("error")
|
|
.or_else(|| value.get("properties").and_then(|props| props.get("error")));
|
|
error
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
.or_else(|| {
|
|
error
|
|
.and_then(|e| e.get("message"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.or_else(|| {
|
|
error
|
|
.and_then(|e| e.get("data"))
|
|
.and_then(|data| data.get("message"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.or_else(|| {
|
|
error
|
|
.and_then(|e| e.get("name"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned())
|
|
}
|
|
|
|
fn tool_label(value: &Value) -> String {
|
|
let part = value.get("part").unwrap_or(value);
|
|
json_string_field(part, &["tool", "name"])
|
|
.or_else(|| json_string_field(value, &["tool", "name"]))
|
|
.unwrap_or_else(|| "outil OpenCode".to_owned())
|
|
}
|
|
|
|
fn tool_preview(value: &Value) -> Option<String> {
|
|
let part = value.get("part").unwrap_or(value);
|
|
non_blank_string(part.get("arguments"))
|
|
.or_else(|| non_blank_string(part.get("input")))
|
|
.or_else(|| non_blank_string(part.get("params")))
|
|
.or_else(|| non_blank_string(part.get("command")))
|
|
.or_else(|| non_blank_string(value.get("arguments")))
|
|
.or_else(|| non_blank_string(value.get("input")))
|
|
.or_else(|| non_blank_string(value.get("params")))
|
|
.or_else(|| non_blank_string(value.get("command")))
|
|
.or_else(|| value_preview(part.get("arguments")))
|
|
.or_else(|| value_preview(part.get("input")))
|
|
.or_else(|| value_preview(part.get("params")))
|
|
.or_else(|| value_preview(value.get("arguments")))
|
|
.or_else(|| value_preview(value.get("input")))
|
|
.or_else(|| value_preview(value.get("params")))
|
|
}
|
|
|
|
fn non_blank_string(value: Option<&Value>) -> Option<String> {
|
|
value
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|text| !text.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
fn value_preview(value: Option<&Value>) -> Option<String> {
|
|
value
|
|
.filter(|value| value.is_object() || value.is_array())
|
|
.map(Value::to_string)
|
|
.filter(|text| !text.trim().is_empty())
|
|
}
|
|
|
|
/// Convertit des lignes JSONL OpenCode en événements domaine.
|
|
///
|
|
/// Le `Final` est la concaténation ordonnée des événements `text` du tour courant.
|
|
/// Si OpenCode rejoue des événements d'une même session, les identifiants de
|
|
/// message (`messageID`/`messageId`) servent à conserver seulement le dernier
|
|
/// message assistant observable. Un tour terminé sans texte exploitable est une
|
|
/// erreur de décodage, pas un succès vide.
|
|
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
|
parse_jsonl_turn_scoped(lines, None)
|
|
}
|
|
|
|
fn parse_jsonl_turn_scoped(
|
|
lines: &[String],
|
|
expected_session_id: Option<&str>,
|
|
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
|
let records = current_turn_records(lines, expected_session_id)?;
|
|
events_from_records(records)
|
|
}
|
|
|
|
fn events_from_records(records: Vec<ParsedRecord>) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
|
let mut events = Vec::new();
|
|
let mut final_text = String::new();
|
|
let mut error_seen = false;
|
|
for record in records {
|
|
append_record_events(record, &mut events, &mut final_text, &mut error_seen);
|
|
}
|
|
append_final_event(events, final_text, error_seen)
|
|
}
|
|
|
|
fn append_record_events(
|
|
record: ParsedRecord,
|
|
events: &mut Vec<ReplyEvent>,
|
|
final_text: &mut String,
|
|
error_seen: &mut bool,
|
|
) {
|
|
match record.event {
|
|
ParsedEvent::StepStart => {
|
|
events.push(ReplyEvent::Progress {
|
|
progress: provider_progress(
|
|
ReplyProgressKind::Turn,
|
|
ReplyProgressStage::Started,
|
|
"étape démarrée",
|
|
"step_start",
|
|
),
|
|
});
|
|
events.push(ReplyEvent::Heartbeat);
|
|
}
|
|
ParsedEvent::StepFinish => {
|
|
events.push(ReplyEvent::Progress {
|
|
progress: provider_progress(
|
|
ReplyProgressKind::Turn,
|
|
ReplyProgressStage::Completed,
|
|
"étape terminée",
|
|
"step_finish",
|
|
),
|
|
});
|
|
events.push(ReplyEvent::Heartbeat);
|
|
}
|
|
ParsedEvent::ToolActivity { label, preview } => {
|
|
let mut progress = tool_progress(&label);
|
|
if let Some(preview) = preview {
|
|
progress = progress.with_text(preview);
|
|
}
|
|
events.push(ReplyEvent::Progress { progress });
|
|
events.push(ReplyEvent::ToolActivity { label });
|
|
}
|
|
ParsedEvent::Text(text) => {
|
|
if !text.is_empty() {
|
|
final_text.push_str(&text);
|
|
events.push(ReplyEvent::TextDelta { text });
|
|
}
|
|
}
|
|
ParsedEvent::Error(message) => {
|
|
*error_seen = true;
|
|
events.push(ReplyEvent::Error { message });
|
|
}
|
|
ParsedEvent::Ignored => {}
|
|
}
|
|
}
|
|
|
|
fn append_final_event(
|
|
mut events: Vec<ReplyEvent>,
|
|
final_text: String,
|
|
error_seen: bool,
|
|
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
|
if final_text.trim().is_empty() {
|
|
if error_seen {
|
|
return Ok(events);
|
|
}
|
|
return Err(AgentSessionError::Decode(
|
|
"OpenCode n'a produit aucun final textuel exploitable".to_owned(),
|
|
));
|
|
}
|
|
events.push(ReplyEvent::Final {
|
|
content: final_text,
|
|
});
|
|
Ok(events)
|
|
}
|
|
|
|
fn provider_progress(
|
|
kind: ReplyProgressKind,
|
|
stage: ReplyProgressStage,
|
|
label: impl Into<String>,
|
|
native_event: impl Into<String>,
|
|
) -> ReplyProgress {
|
|
ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label)
|
|
.with_provider_event("opencode", native_event)
|
|
}
|
|
|
|
fn tool_progress(label: &str) -> ReplyProgress {
|
|
if label.starts_with("idea_") {
|
|
ReplyProgress::new(
|
|
ReplyProgressSource::IdeaLocal,
|
|
ReplyProgressKind::Mcp,
|
|
ReplyProgressStage::Completed,
|
|
label.to_owned(),
|
|
)
|
|
.with_tool_name(label.to_owned())
|
|
} else {
|
|
provider_progress(
|
|
ReplyProgressKind::Tool,
|
|
ReplyProgressStage::Started,
|
|
label.to_owned(),
|
|
"tool_use",
|
|
)
|
|
.with_tool_name(label.to_owned())
|
|
}
|
|
}
|
|
|
|
fn send_tap(tap: &std::sync::mpsc::Sender<ReplyEvent>, event: ReplyEvent) {
|
|
if !matches!(event, ReplyEvent::Final { .. }) {
|
|
let _ = tap.send(event);
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct LiveTurnProjector {
|
|
expected_session_id: Option<String>,
|
|
active_message_id: Option<String>,
|
|
seen_message_id: bool,
|
|
hold_until_new_message: bool,
|
|
pending: Vec<ParsedRecord>,
|
|
ready: Vec<ReplyEvent>,
|
|
final_text: String,
|
|
error_seen: bool,
|
|
}
|
|
|
|
impl LiveTurnProjector {
|
|
fn new(expected_session_id: Option<String>) -> Self {
|
|
Self {
|
|
hold_until_new_message: expected_session_id.is_some(),
|
|
expected_session_id,
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
fn ingest(&mut self, record: ParsedRecord) -> Vec<ReplyEvent> {
|
|
if let (Some(expected), Some(actual)) = (
|
|
self.expected_session_id.as_deref(),
|
|
record.session_id.as_deref(),
|
|
) {
|
|
if actual != expected {
|
|
return Vec::new();
|
|
}
|
|
}
|
|
if matches!(record.event, ParsedEvent::Ignored) {
|
|
return Vec::new();
|
|
}
|
|
|
|
if let Some(message_id) = record.message_id.clone() {
|
|
if self.active_message_id.as_deref() != Some(message_id.as_str()) {
|
|
if self.seen_message_id {
|
|
self.pending.clear();
|
|
self.hold_until_new_message = false;
|
|
}
|
|
self.active_message_id = Some(message_id);
|
|
self.seen_message_id = true;
|
|
}
|
|
}
|
|
|
|
if self.hold_until_new_message && self.seen_message_id {
|
|
self.pending.push(record);
|
|
return Vec::new();
|
|
}
|
|
|
|
self.emit_record(record)
|
|
}
|
|
|
|
fn finish(mut self) -> Vec<ReplyEvent> {
|
|
let pending = std::mem::take(&mut self.pending);
|
|
let mut out = Vec::new();
|
|
for record in pending {
|
|
out.extend(self.emit_record(record));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn emit_record(&mut self, record: ParsedRecord) -> Vec<ReplyEvent> {
|
|
let before = self.ready.len();
|
|
append_record_events(
|
|
record,
|
|
&mut self.ready,
|
|
&mut self.final_text,
|
|
&mut self.error_seen,
|
|
);
|
|
self.ready.split_off(before)
|
|
}
|
|
}
|
|
|
|
fn current_turn_records(
|
|
lines: &[String],
|
|
expected_session_id: Option<&str>,
|
|
) -> Result<Vec<ParsedRecord>, AgentSessionError> {
|
|
let mut records = Vec::new();
|
|
for line in lines {
|
|
let record = parse_jsonl_record(line)?;
|
|
if let (Some(expected), Some(actual)) = (expected_session_id, record.session_id.as_deref())
|
|
{
|
|
if actual != expected {
|
|
continue;
|
|
}
|
|
}
|
|
if !matches!(record.event, ParsedEvent::Ignored) {
|
|
records.push(record);
|
|
}
|
|
}
|
|
let Some(current_message_id) = records.iter().rev().find_map(|record| {
|
|
matches!(
|
|
record.event,
|
|
ParsedEvent::Text(_)
|
|
| ParsedEvent::ToolActivity { .. }
|
|
| ParsedEvent::StepStart
|
|
| ParsedEvent::StepFinish
|
|
| ParsedEvent::Error(_)
|
|
)
|
|
.then(|| record.message_id.as_deref())
|
|
.flatten()
|
|
.map(str::to_owned)
|
|
}) else {
|
|
return Ok(records);
|
|
};
|
|
Ok(records
|
|
.into_iter()
|
|
.filter(|record| {
|
|
record
|
|
.message_id
|
|
.as_deref()
|
|
.is_none_or(|message_id| message_id == current_message_id.as_str())
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool {
|
|
events
|
|
.iter()
|
|
.any(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }))
|
|
}
|
|
|
|
fn stream_events(events: Vec<ReplyEvent>, has_tap: bool) -> Vec<ReplyEvent> {
|
|
if !has_tap {
|
|
return events;
|
|
}
|
|
events
|
|
.into_iter()
|
|
.filter(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }))
|
|
.collect()
|
|
}
|
|
|
|
/// Découpe une commande utilisateur en argv sans shell implicite.
|
|
///
|
|
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
|
|
/// pipes et substitutions ne sont pas interprétés.
|
|
pub fn split_command_prefix(raw: &str) -> Result<Vec<String>, AgentSessionError> {
|
|
let mut out = Vec::new();
|
|
let mut cur = String::new();
|
|
let mut chars = raw.chars().peekable();
|
|
let mut quote: Option<char> = None;
|
|
while let Some(ch) = chars.next() {
|
|
match (quote, ch) {
|
|
(Some(q), c) if c == q => quote = None,
|
|
(None, '\'' | '"') => quote = Some(ch),
|
|
(_, '\\') => {
|
|
if let Some(next) = chars.next() {
|
|
cur.push(next);
|
|
} else {
|
|
cur.push('\\');
|
|
}
|
|
}
|
|
(None, c) if c.is_whitespace() => {
|
|
if !cur.is_empty() {
|
|
out.push(std::mem::take(&mut cur));
|
|
}
|
|
}
|
|
(_, c) => cur.push(c),
|
|
}
|
|
}
|
|
if quote.is_some() {
|
|
return Err(AgentSessionError::Start(
|
|
"commande OpenCode invalide: guillemet non fermé".to_owned(),
|
|
));
|
|
}
|
|
if !cur.is_empty() {
|
|
out.push(cur);
|
|
}
|
|
if out.is_empty() {
|
|
return Err(AgentSessionError::Start(
|
|
"commande OpenCode vide".to_owned(),
|
|
));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Adapter OpenCode process-backed.
|
|
pub struct OpenCodeSession {
|
|
id: SessionId,
|
|
command: String,
|
|
prefix_args: Vec<String>,
|
|
cwd: String,
|
|
env: Vec<(String, String)>,
|
|
engine_session_id: Mutex<Option<String>>,
|
|
current_child: Mutex<Option<Arc<AsyncMutex<tokio::process::Child>>>>,
|
|
sandbox: Option<SandboxPlan>,
|
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
|
}
|
|
|
|
impl OpenCodeSession {
|
|
fn clear_current_child(&self, child: &Arc<AsyncMutex<tokio::process::Child>>) {
|
|
let mut current = self
|
|
.current_child
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if current
|
|
.as_ref()
|
|
.is_some_and(|stored| Arc::ptr_eq(stored, child))
|
|
{
|
|
*current = None;
|
|
}
|
|
}
|
|
|
|
/// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu,
|
|
/// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`.
|
|
pub fn new(
|
|
id: SessionId,
|
|
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>>,
|
|
) -> Result<Self, AgentSessionError> {
|
|
let mut prefix = split_command_prefix(&command_prefix.into())?;
|
|
let command = prefix.remove(0);
|
|
prefix.extend(profile_args);
|
|
Ok(Self {
|
|
id,
|
|
command,
|
|
prefix_args: prefix,
|
|
cwd: cwd.into(),
|
|
env,
|
|
engine_session_id: Mutex::new(seed),
|
|
current_child: Mutex::new(None),
|
|
sandbox,
|
|
sandbox_enforcer,
|
|
})
|
|
}
|
|
|
|
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()]);
|
|
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]
|
|
impl AgentSession for OpenCodeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
|
|
fn conversation_id(&self) -> Option<String> {
|
|
self.engine_session_id
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone()
|
|
}
|
|
|
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
self.send_inner(prompt, None).await
|
|
}
|
|
|
|
async fn send_with_tap(
|
|
&self,
|
|
prompt: &str,
|
|
tap: std::sync::mpsc::Sender<ReplyEvent>,
|
|
) -> Result<ReplyStream, AgentSessionError> {
|
|
self.send_inner(prompt, Some(tap)).await
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
|
|
let child = self
|
|
.current_child
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone();
|
|
if let Some(child) = child {
|
|
child
|
|
.lock()
|
|
.await
|
|
.kill()
|
|
.await
|
|
.map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl OpenCodeSession {
|
|
async fn send_inner(
|
|
&self,
|
|
prompt: &str,
|
|
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
|
|
) -> Result<ReplyStream, AgentSessionError> {
|
|
let mut cmd = Command::new(&self.command);
|
|
cmd.args(self.build_args(prompt))
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if !self.cwd.is_empty() && self.cwd != "/" {
|
|
cmd.current_dir(&self.cwd);
|
|
}
|
|
for (key, value) in &self.env {
|
|
cmd.env(key, value);
|
|
}
|
|
// OpenCode supporte déjà son propre confinement logique; le plan Landlock
|
|
// structuré reste réservé aux chemins process génériques existants.
|
|
let _ = (&self.sandbox, &self.sandbox_enforcer);
|
|
|
|
let child = cmd
|
|
.spawn()
|
|
.map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?;
|
|
let child = Arc::new(AsyncMutex::new(child));
|
|
{
|
|
let mut current = self
|
|
.current_child
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*current = Some(Arc::clone(&child));
|
|
}
|
|
let outcome = async {
|
|
let (stdout, mut stderr_pipe) =
|
|
{
|
|
let mut locked = child.lock().await;
|
|
let stdout = locked.stdout.take().ok_or_else(|| {
|
|
AgentSessionError::Io("stdout pipe indisponible".to_owned())
|
|
})?;
|
|
let stderr = locked.stderr.take().ok_or_else(|| {
|
|
AgentSessionError::Io("stderr pipe indisponible".to_owned())
|
|
})?;
|
|
(stdout, stderr)
|
|
};
|
|
|
|
let expected_session = self.conversation_id();
|
|
let mut lines = BufReader::new(stdout).lines();
|
|
let mut collected = Vec::new();
|
|
let mut live = tap
|
|
.as_ref()
|
|
.map(|_| LiveTurnProjector::new(expected_session.clone()));
|
|
while let Some(line) = lines
|
|
.next_line()
|
|
.await
|
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?
|
|
{
|
|
let record = parse_jsonl_record(&line)?;
|
|
if let Some(engine_id) = record.session_id.clone() {
|
|
if expected_session
|
|
.as_deref()
|
|
.is_none_or(|expected| expected == engine_id)
|
|
{
|
|
self.capture_session_id(engine_id);
|
|
}
|
|
}
|
|
if let (Some(tap), Some(live)) = (&tap, live.as_mut()) {
|
|
for event in live.ingest(record) {
|
|
send_tap(tap, event);
|
|
}
|
|
}
|
|
collected.push(line);
|
|
}
|
|
if let (Some(tap), Some(live)) = (&tap, live) {
|
|
for event in live.finish() {
|
|
send_tap(tap, event);
|
|
}
|
|
}
|
|
|
|
let mut stderr_bytes = Vec::new();
|
|
stderr_pipe
|
|
.read_to_end(&mut stderr_bytes)
|
|
.await
|
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
|
let status = child
|
|
.lock()
|
|
.await
|
|
.wait()
|
|
.await
|
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
|
Ok((expected_session, collected, stderr_bytes, status))
|
|
}
|
|
.await;
|
|
self.clear_current_child(&child);
|
|
let (expected_session, collected, stderr_bytes, status) = outcome?;
|
|
let stderr = String::from_utf8_lossy(&stderr_bytes);
|
|
let parsed_session = expected_session.or_else(|| self.conversation_id());
|
|
let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref());
|
|
let has_tap = tap.is_some();
|
|
if !status.success() {
|
|
if let Ok(events) = parsed {
|
|
if has_structured_terminal_event(&events) {
|
|
return Ok(Box::new(stream_events(events, has_tap).into_iter()));
|
|
}
|
|
}
|
|
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
|
|
return Err(AgentSessionError::Start(
|
|
"serveur MCP OpenCode `idea` indisponible".to_owned(),
|
|
));
|
|
}
|
|
return Err(AgentSessionError::Io(format!(
|
|
"OpenCode a quitté avec le statut {}: {}",
|
|
status,
|
|
stderr.trim()
|
|
)));
|
|
}
|
|
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
|
|
return Err(AgentSessionError::Start(
|
|
"serveur MCP OpenCode `idea` indisponible".to_owned(),
|
|
));
|
|
}
|
|
|
|
let events = parsed?;
|
|
Ok(Box::new(stream_events(events, has_tap).into_iter()))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[cfg(unix)]
|
|
use std::fs;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
#[cfg(unix)]
|
|
use std::path::{Path, PathBuf};
|
|
#[cfg(unix)]
|
|
use std::time::Duration;
|
|
|
|
use super::*;
|
|
|
|
fn without_progress(events: Vec<ReplyEvent>) -> Vec<ReplyEvent> {
|
|
events
|
|
.into_iter()
|
|
.filter(|event| !matches!(event, ReplyEvent::Progress { .. }))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn split_command_prefix_handles_quotes_without_shell() {
|
|
assert_eq!(
|
|
split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(),
|
|
vec!["/tmp/my opencode", "--flag", "two words", "three words"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
|
|
let events = parse_jsonl_turn(&[
|
|
r#"{"type":"step_start"}"#.to_owned(),
|
|
r#"{"type":"text","part":{"text":"hel"}}"#.to_owned(),
|
|
r#"{"type":"text","part":{"text":"lo"}}"#.to_owned(),
|
|
r#"{"type":"step_finish"}"#.to_owned(),
|
|
])
|
|
.unwrap();
|
|
assert_eq!(
|
|
without_progress(events),
|
|
vec![
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::TextDelta {
|
|
text: "hel".to_owned()
|
|
},
|
|
ReplyEvent::TextDelta {
|
|
text: "lo".to_owned()
|
|
},
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::Final {
|
|
content: "hello".to_owned()
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_rejects_empty_final() {
|
|
let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err();
|
|
assert!(matches!(err, AgentSessionError::Decode(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_rejects_tool_only_finished_turn() {
|
|
let err = parse_jsonl_turn(&[
|
|
r#"{"type":"step_start"}"#.to_owned(),
|
|
r#"{"type":"step_finish"}"#.to_owned(),
|
|
])
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, AgentSessionError::Decode(ref message) if message.contains("aucun final textuel exploitable")),
|
|
"tool-only turn must be actionable, not a fake empty success: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_rejects_truly_empty_or_ignored_turn() {
|
|
let empty = parse_jsonl_turn(&[]).unwrap_err();
|
|
assert!(matches!(empty, AgentSessionError::Decode(_)));
|
|
let ignored =
|
|
parse_jsonl_turn(&[r#"{"type":"session","id":"s1"}"#.to_owned()]).unwrap_err();
|
|
assert!(matches!(ignored, AgentSessionError::Decode(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_event_rejects_invalid_json() {
|
|
let err = parse_jsonl_event("{nope").unwrap_err();
|
|
assert!(matches!(err, AgentSessionError::Decode(_)));
|
|
}
|
|
|
|
/// Format JSONL réel de `opencode run --format json`, confirmé par lecture du
|
|
/// source `packages/opencode/src/cli/cmd/run.ts` : le texte est niché sous
|
|
/// `.part.text`, pas à la racine.
|
|
fn opencode_script() -> Vec<&'static str> {
|
|
vec![
|
|
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#,
|
|
r#"{"type":"text","timestamp":2,"sessionID":"s1","part":{"id":"p1","type":"text","text":"réponse réelle","time":{"start":1,"end":2}}}"#,
|
|
r#"{"type":"step_finish","timestamp":3,"sessionID":"s1"}"#,
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_reads_text_nested_under_part() {
|
|
let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect();
|
|
let events = parse_jsonl_turn(&lines).unwrap();
|
|
assert_eq!(
|
|
without_progress(events),
|
|
vec![
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::TextDelta {
|
|
text: "réponse réelle".to_owned()
|
|
},
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::Final {
|
|
content: "réponse réelle".to_owned()
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_emits_error_event() {
|
|
let events = parse_jsonl_turn(&[
|
|
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#.to_owned(),
|
|
r#"{"type":"error","timestamp":2,"sessionID":"s1","error":{"message":"quelque chose a échoué"}}"#
|
|
.to_owned(),
|
|
])
|
|
.unwrap();
|
|
assert_eq!(
|
|
without_progress(events),
|
|
vec![
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::Error {
|
|
message: "quelque chose a échoué".to_owned()
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_event_error_accepts_raw_string() {
|
|
let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
|
|
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_event_accepts_tool_use() {
|
|
let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap();
|
|
assert_eq!(
|
|
event,
|
|
ParsedEvent::ToolActivity {
|
|
label: "bash".to_owned(),
|
|
preview: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_tags_idea_tools_as_mcp_progress() {
|
|
let events = parse_jsonl_turn(&[
|
|
r#"{"type":"tool_use","part":{"tool":"idea_ask_agent"}}"#.to_owned(),
|
|
r#"{"type":"text","part":{"text":"ok"}}"#.to_owned(),
|
|
])
|
|
.unwrap();
|
|
|
|
assert!(
|
|
events.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::Progress { progress }
|
|
if progress.source == ReplyProgressSource::IdeaLocal
|
|
&& progress.kind == ReplyProgressKind::Mcp
|
|
&& progress.stage == ReplyProgressStage::Completed
|
|
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
|
|
)),
|
|
"les tools idea_* doivent être surfacés comme MCP IdeA local: {events:?}"
|
|
);
|
|
assert!(
|
|
!events.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::Progress { progress }
|
|
if progress.source == ReplyProgressSource::ProviderNative
|
|
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
|
|
)),
|
|
"le tool MCP ne doit pas produire une double carte provider-native: {events:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_keeps_latest_message_when_opencode_replays_history() {
|
|
let events = parse_jsonl_turn(&[
|
|
r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"old","type":"step-start"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"text","sessionID":"s1","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"old","type":"step-finish"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"new","type":"step-start"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"text","sessionID":"s1","part":{"messageID":"new","type":"text","text":"réponse courante"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"new","type":"step-finish"}}"#
|
|
.to_owned(),
|
|
])
|
|
.unwrap();
|
|
assert_eq!(
|
|
events.last(),
|
|
Some(&ReplyEvent::Final {
|
|
content: "réponse courante".to_owned()
|
|
})
|
|
);
|
|
assert!(
|
|
!events.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::TextDelta { text } if text.contains("ancienne")
|
|
)),
|
|
"replayed text from a previous message must not pollute the current final: {events:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jsonl_turn_scoped_ignores_other_sessions_when_known() {
|
|
let events = parse_jsonl_turn_scoped(
|
|
&[
|
|
r#"{"type":"text","sessionID":"other","part":{"messageID":"m1","text":"pollution"}}"#
|
|
.to_owned(),
|
|
r#"{"type":"text","sessionID":"current","part":{"messageID":"m2","text":"ok"}}"#
|
|
.to_owned(),
|
|
],
|
|
Some("current"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
events.last(),
|
|
Some(&ReplyEvent::Final {
|
|
content: "ok".to_owned()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct TempDir(PathBuf);
|
|
|
|
#[cfg(unix)]
|
|
impl TempDir {
|
|
fn new(label: &str) -> Self {
|
|
let path = std::env::temp_dir()
|
|
.join(format!("idea-opencode-{label}-{}", uuid::Uuid::new_v4()));
|
|
fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_executable_script(path: &Path, body: impl AsRef<str>) {
|
|
fs::write(path, body.as_ref()).unwrap();
|
|
let mut permissions = fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn send_prefers_parseable_stdout_over_nonzero_exit_status() {
|
|
let tmp = TempDir::new("exit-one");
|
|
let script = tmp.path().join("opencode-fixture.sh");
|
|
fs::write(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
printf '%s\n' '{"type":"step_start"}'
|
|
printf '%s\n' '{"type":"text","part":{"text":"résultat exploitable"}}'
|
|
printf '%s\n' '{"type":"step_finish"}'
|
|
printf '%s\n' 'stderr générique' >&2
|
|
exit 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();
|
|
|
|
let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
without_progress(events),
|
|
vec![
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::TextDelta {
|
|
text: "résultat exploitable".to_owned()
|
|
},
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::Final {
|
|
content: "résultat exploitable".to_owned()
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() {
|
|
let tmp = TempDir::new("tap-live");
|
|
let script = tmp.path().join("opencode-fixture.sh");
|
|
write_executable_script(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
printf '%s\n' '{"type":"step_start","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-start"}}'
|
|
sleep 1
|
|
printf '%s\n' '{"type":"tool_use","sessionID":"fresh-engine","part":{"messageID":"m1","type":"tool","tool":"idea_ask_agent"}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"fresh-engine","part":{"messageID":"m1","type":"text","text":"réponse live"}}'
|
|
printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-finish"}}'
|
|
"#,
|
|
);
|
|
let session = Arc::new(
|
|
OpenCodeSession::new(
|
|
SessionId::new_random(),
|
|
script.to_string_lossy(),
|
|
Vec::new(),
|
|
tmp.path().to_string_lossy(),
|
|
None,
|
|
Vec::new(),
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap(),
|
|
);
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
let worker = {
|
|
let session = Arc::clone(&session);
|
|
tokio::spawn(async move {
|
|
session
|
|
.send_with_tap("prompt", tx)
|
|
.await
|
|
.map(|stream| stream.collect::<Vec<_>>())
|
|
})
|
|
};
|
|
|
|
let (first_live, rx) = tokio::task::spawn_blocking(move || {
|
|
let first_live = rx
|
|
.recv_timeout(Duration::from_millis(500))
|
|
.expect("un événement de progress doit atteindre le tap avant le Final");
|
|
(first_live, rx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
matches!(
|
|
first_live,
|
|
ReplyEvent::Progress { ref progress }
|
|
if progress.native_event.as_deref() == Some("step_start")
|
|
),
|
|
"le premier événement live doit être le step_start provider: {first_live:?}"
|
|
);
|
|
|
|
let returned = worker.await.unwrap().unwrap();
|
|
let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect();
|
|
|
|
assert!(
|
|
live.iter()
|
|
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
|
|
"le tap ne doit pas recevoir le Final autoritaire: {live:?}"
|
|
);
|
|
assert!(
|
|
live.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::Progress { progress }
|
|
if progress.source == ReplyProgressSource::IdeaLocal
|
|
&& progress.kind == ReplyProgressKind::Mcp
|
|
&& progress.stage == ReplyProgressStage::Completed
|
|
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
|
|
)),
|
|
"le tool_use idea_* doit produire une progression MCP locale: {live:?}"
|
|
);
|
|
assert_eq!(
|
|
returned,
|
|
vec![ReplyEvent::Final {
|
|
content: "réponse live".to_owned()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn send_with_tap_does_not_replay_previous_message_into_live_stream() {
|
|
let tmp = TempDir::new("tap-replay");
|
|
let script = tmp.path().join("opencode-fixture.sh");
|
|
write_executable_script(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-start"}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}'
|
|
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-finish"}}'
|
|
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-start"}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"new","type":"text","text":"réponse courante"}}'
|
|
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-finish"}}'
|
|
"#,
|
|
);
|
|
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();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
let returned = session
|
|
.send_with_tap("prompt", tx)
|
|
.await
|
|
.unwrap()
|
|
.collect::<Vec<_>>();
|
|
let live: Vec<_> = rx.into_iter().collect();
|
|
|
|
assert!(
|
|
!live.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::TextDelta { text } if text.contains("ancienne")
|
|
)),
|
|
"le texte rejoué ne doit pas polluer le tap live: {live:?}"
|
|
);
|
|
assert!(
|
|
live.iter().any(|event| matches!(
|
|
event,
|
|
ReplyEvent::TextDelta { text } if text == "réponse courante"
|
|
)),
|
|
"le message courant doit rester visible dans le tap live: {live:?}"
|
|
);
|
|
assert_eq!(
|
|
returned,
|
|
vec![ReplyEvent::Final {
|
|
content: "réponse courante".to_owned()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn seeded_session_ignores_and_does_not_capture_other_session_events() {
|
|
let tmp = TempDir::new("ignore-other-session");
|
|
let script = tmp.path().join("opencode-fixture.sh");
|
|
fs::write(
|
|
&script,
|
|
r#"#!/bin/sh
|
|
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"m1","text":"réponse seed"}}'
|
|
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
|
|
printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}'
|
|
"#,
|
|
)
|
|
.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();
|
|
|
|
let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
events.last(),
|
|
Some(&ReplyEvent::Final {
|
|
content: "réponse seed".to_owned()
|
|
})
|
|
);
|
|
assert_eq!(session.conversation_id().as_deref(), Some("seeded-engine"));
|
|
}
|
|
}
|