Files
IdeA/crates/infrastructure/src/session/openai_compat.rs
Blomios ee0ffd4e2d fix(chat): réattache persistante CLI custom et rendu tools/MCP/progress enrichi (#188)
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.
2026-08-11 18:49:12 +02:00

1386 lines
48 KiB
Rust

//! Adapter de session HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
//!
//! Contrairement aux adapters CLI, la boucle agentique est conduite par IdeA :
//! transcript local, appels HTTP `/chat/completions`, invocation in-process des
//! outils, puis rebouclage borné.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use domain::ports::{
AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
ReplyProgressSource, ReplyProgressStage, ReplyStream, ToolInvoker, ToolSpec,
};
use domain::profile::{HttpChatConfig, StructuredAdapter};
use domain::SessionId;
const TRANSCRIPT_FILE: &str = "chat-transcript.json";
/// Appel d'outil OpenAI-compatible normalisé pour la boucle agentique.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenAiToolCall {
/// Identifiant OpenAI-compatible de l'appel d'outil.
pub id: String,
/// Nom de la fonction appelée.
pub name: String,
/// Arguments JSON bruts fournis par le modèle.
pub arguments: String,
}
/// Résultat d'un chunk streaming OpenAI-compatible.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedChatDelta {
/// Fragment de texte assistant.
pub text: String,
/// Fragment de raisonnement exposé par certains serveurs OpenAI-compatibles.
pub reasoning_content: String,
/// Appels d'outils annoncés par ce chunk.
pub tool_calls: Vec<OpenAiToolCall>,
}
/// Résultat d'une completion non-streaming OpenAI-compatible.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedCompletion {
/// Contenu assistant complet.
pub content: String,
/// Contenu alternatif de raisonnement, quand le serveur laisse `content` vide.
pub reasoning_content: String,
/// Appels d'outils demandés par le modèle.
pub tool_calls: Vec<OpenAiToolCall>,
}
impl ParsedCompletion {
fn visible_content(&self) -> String {
if self.content.trim().is_empty() {
self.reasoning_content.clone()
} else {
self.content.clone()
}
}
}
#[derive(Debug, Default)]
struct StreamState {
content: String,
reasoning_content: String,
tool_calls: Vec<ToolCallBuilder>,
}
#[derive(Debug, Clone, Default)]
struct ToolCallBuilder {
id: String,
name: String,
arguments: String,
}
impl ToolCallBuilder {
fn finish(self) -> Option<OpenAiToolCall> {
if self.name.is_empty() {
None
} else {
Some(OpenAiToolCall {
id: if self.id.is_empty() {
format!("call_{}", self.name)
} else {
self.id
},
name: self.name,
arguments: self.arguments,
})
}
}
}
/// Parse un chunk SSE `chat.completion.chunk` OpenAI-compatible.
///
/// # Errors
/// [`AgentSessionError::Decode`] si le JSON est illisible. Le payload brut ne fuit
/// jamais dans le message.
pub fn parse_chat_delta(line: &str) -> Result<ParsedChatDelta, AgentSessionError> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "[DONE]" {
return Ok(ParsedChatDelta::default());
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("chat delta JSON illisible: {e}")))?;
let Some(delta) = value
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
else {
return Ok(ParsedChatDelta::default());
};
let text = delta
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let reasoning_content = delta
.get("reasoning_content")
.or_else(|| delta.get("reasoningContent"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let tool_calls = parse_tool_calls_array(delta.get("tool_calls"));
Ok(ParsedChatDelta {
text,
reasoning_content,
tool_calls,
})
}
/// Parse une réponse non-streaming `/chat/completions`.
///
/// # Errors
/// [`AgentSessionError::Decode`] si le JSON est illisible. Le payload brut ne fuit
/// jamais dans le message.
pub fn parse_completion(body: &str) -> Result<ParsedCompletion, AgentSessionError> {
let value: Value = serde_json::from_str(body)
.map_err(|e| AgentSessionError::Decode(format!("chat completion JSON illisible: {e}")))?;
let Some(message) = value
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
else {
return Err(AgentSessionError::Decode(
"chat completion schema missing choices[0].message".to_owned(),
));
};
let content = message
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let reasoning_content = message
.get("reasoning_content")
.or_else(|| message.get("reasoningContent"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
Ok(ParsedCompletion {
content,
reasoning_content,
tool_calls: parse_tool_calls_array(message.get("tool_calls")),
})
}
fn parse_tool_calls_array(value: Option<&Value>) -> Vec<OpenAiToolCall> {
value
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let function = item.get("function")?;
let name = function
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let arguments = function
.get("arguments")
.and_then(Value::as_str)
.unwrap_or_default();
if name.is_empty() && arguments.is_empty() {
return None;
}
Some(OpenAiToolCall {
id: item
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
name: name.to_owned(),
arguments: arguments.to_owned(),
})
})
.collect()
}
/// Session structurée OpenAI-compatible, stateless côté provider et persistée côté IdeA.
pub struct OpenAiCompatibleSession {
id: SessionId,
client: Client,
config: HttpChatConfig,
endpoint: String,
api_key: Option<String>,
transcript_path: PathBuf,
transcript: Mutex<Vec<Value>>,
tool_invoker: Option<Arc<dyn ToolInvoker>>,
tools_disabled: Mutex<bool>,
tools_rejection_reported: Mutex<bool>,
first_contact: Mutex<bool>,
}
impl OpenAiCompatibleSession {
/// Construit l'adapter depuis une config domaine validée.
///
/// # Errors
/// [`AgentSessionError::Start`] si la clé d'environnement déclarée est absente
/// ou si le client HTTP ne peut pas être construit.
pub fn new(
id: SessionId,
config: HttpChatConfig,
run_dir: impl AsRef<Path>,
system_context: &str,
tool_invoker: Option<Arc<dyn ToolInvoker>>,
) -> Result<Self, AgentSessionError> {
let mut builder = Client::builder();
if let Some(ms) = config.connect_timeout_ms {
builder = builder.connect_timeout(Duration::from_millis(u64::from(ms)));
}
if let Some(ms) = config.request_timeout_ms {
builder = builder.timeout(Duration::from_millis(u64::from(ms)));
}
let client = builder
.build()
.map_err(|e| AgentSessionError::Start(format!("client HTTP invalide: {e}")))?;
let api_key = match &config.api_key_env {
Some(var) => Some(std::env::var(var).map_err(|_| {
AgentSessionError::Start(format!(
"variable d'environnement `{var}` introuvable pour la clé API"
))
})?),
None => None,
};
let transcript_path = run_dir.as_ref().join(TRANSCRIPT_FILE);
let transcript = load_or_seed_transcript(&transcript_path, system_context)?;
Ok(Self {
id,
endpoint: chat_completions_endpoint(&config.endpoint),
client,
config,
api_key,
transcript_path,
transcript: Mutex::new(transcript),
tool_invoker,
tools_disabled: Mutex::new(false),
tools_rejection_reported: Mutex::new(false),
first_contact: Mutex::new(true),
})
}
async fn send_inner(
&self,
prompt: &str,
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Result<ReplyStream, AgentSessionError> {
{
let mut transcript = self.transcript.lock().expect("mutex sain");
transcript.push(json!({"role": "user", "content": prompt}));
}
let mut events = vec![ReplyEvent::Heartbeat];
send_tap(&tap, &ReplyEvent::Heartbeat);
for iteration in 0..=self.config.effective_max_tool_iterations() {
let transcript = self.transcript.lock().expect("mutex sain").clone();
let tools = self.effective_tools().await?;
let response = self.post_chat(&transcript, &tools, true).await;
let response = match response {
Ok(response) => response,
Err(err) => {
if should_retry_without_tools(&err, !tools.is_empty()) {
self.disable_tools_once(&mut events, &tap);
let transcript = self.transcript.lock().expect("mutex sain").clone();
self.post_chat(&transcript, &[], true).await?
} else {
return Err(err);
}
}
};
let turn = self.consume_response(response, &tap).await?;
let visible_content = turn.visible_content();
events.extend(turn.events);
if !turn.tool_calls.is_empty() {
if iteration >= self.config.effective_max_tool_iterations() {
let content = "Tool-calling stopped: max_tool_iterations reached.".to_owned();
self.transcript
.lock()
.expect("mutex sain")
.push(json!({"role": "assistant", "content": content}));
events.push(ReplyEvent::Final { content });
self.persist_transcript()?;
return Ok(Box::new(events.into_iter()));
}
self.apply_tool_calls(&turn.tool_calls, &mut events, &tap)
.await;
self.persist_transcript()?;
continue;
}
self.transcript
.lock()
.expect("mutex sain")
.push(json!({"role": "assistant", "content": visible_content}));
events.push(ReplyEvent::Final {
content: visible_content,
});
self.persist_transcript()?;
return Ok(Box::new(events.into_iter()));
}
unreachable!("loop returns at max_tool_iterations");
}
async fn effective_tools(&self) -> Result<Vec<ToolSpec>, AgentSessionError> {
if *self.tools_disabled.lock().expect("mutex sain") {
return Ok(Vec::new());
}
match &self.tool_invoker {
Some(invoker) => invoker
.tools_for_bound_context()
.await
.map_err(|e| AgentSessionError::Start(format!("résolution tools: {e}"))),
None => Ok(Vec::new()),
}
}
async fn post_chat(
&self,
messages: &[Value],
tools: &[ToolSpec],
stream: bool,
) -> Result<reqwest::Response, AgentSessionError> {
let mut body = json!({
"model": self.config.model,
"messages": messages,
"stream": stream,
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools.iter().map(tool_spec_to_openai).collect());
}
let mut request = self.client.post(&self.endpoint).json(&body);
if let Some(key) = &self.api_key {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
if response.status().is_success() {
*self.first_contact.lock().expect("mutex sain") = false;
return Ok(response);
}
let status = response.status();
if status == StatusCode::BAD_REQUEST
|| status == StatusCode::NOT_FOUND
|| status == StatusCode::UNPROCESSABLE_ENTITY
{
return Err(AgentSessionError::Start(format!(
"endpoint OpenAI-compatible rejected request with HTTP {status}"
)));
}
Err(AgentSessionError::Io(format!(
"endpoint OpenAI-compatible returned HTTP {status}"
)))
}
async fn consume_response(
&self,
response: reqwest::Response,
tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Result<TurnResult, AgentSessionError> {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if !content_type.contains("text/event-stream") {
let body = response
.text()
.await
.map_err(|e| AgentSessionError::Io(format!("lecture réponse HTTP: {e}")))?;
let parsed = parse_completion(&body)?;
let visible_content = parsed.visible_content();
return Ok(TurnResult {
events: text_events(&visible_content, tap),
content: parsed.content,
reasoning_content: parsed.reasoning_content,
tool_calls: parsed.tool_calls,
});
}
let mut state = StreamState::default();
let mut events = Vec::new();
let mut pending = String::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|e| AgentSessionError::Io(format!("lecture flux SSE: {e}")))?;
pending.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = pending.find('\n') {
let line = pending[..pos].trim_end_matches('\r').to_owned();
pending = pending[pos + 1..].to_owned();
handle_sse_line(&line, &mut state, &mut events, tap)?;
}
}
if !pending.trim().is_empty() {
handle_sse_line(pending.trim_end_matches('\r'), &mut state, &mut events, tap)?;
}
Ok(TurnResult {
events,
content: state.content,
reasoning_content: state.reasoning_content,
tool_calls: state
.tool_calls
.into_iter()
.filter_map(ToolCallBuilder::finish)
.collect(),
})
}
async fn apply_tool_calls(
&self,
tool_calls: &[OpenAiToolCall],
events: &mut Vec<ReplyEvent>,
tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>,
) {
let assistant_calls = tool_calls
.iter()
.map(|call| {
json!({
"id": call.id,
"type": "function",
"function": { "name": call.name, "arguments": call.arguments }
})
})
.collect::<Vec<_>>();
self.transcript.lock().expect("mutex sain").push(json!({
"role": "assistant",
"content": Value::Null,
"tool_calls": assistant_calls,
}));
for call in tool_calls {
let started = local_tool_progress(
ReplyProgressStage::Started,
format!("appel MCP {}", call.name),
&call.name,
Some(call.arguments.as_str()),
);
send_tap(tap, &started);
events.push(started);
let event = ReplyEvent::ToolActivity {
label: call.name.clone(),
};
send_tap(tap, &event);
events.push(event);
let result = match &self.tool_invoker {
Some(invoker) => invoker
.call(&call.name, &call.arguments)
.await
.unwrap_or_else(|e| format!("Tool invocation failed: {e}")),
None => "Tool invocation unavailable".to_owned(),
};
let completed = local_tool_progress(
ReplyProgressStage::Completed,
format!("MCP {} terminé", call.name),
&call.name,
Some(result.as_str()),
);
send_tap(tap, &completed);
events.push(completed);
self.transcript.lock().expect("mutex sain").push(json!({
"role": "tool",
"tool_call_id": call.id,
"content": result,
}));
}
}
fn disable_tools_once(
&self,
events: &mut Vec<ReplyEvent>,
tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>,
) {
*self.tools_disabled.lock().expect("mutex sain") = true;
let mut reported = self.tools_rejection_reported.lock().expect("mutex sain");
if !*reported {
let event = ReplyEvent::Announcement {
text: "Tool calling disabled for this OpenAI-compatible endpoint; retrying chat without tools.".to_owned(),
};
send_tap(tap, &event);
events.push(event);
*reported = true;
}
}
fn map_reqwest_error(&self, e: reqwest::Error) -> AgentSessionError {
if *self.first_contact.lock().expect("mutex sain") && (e.is_connect() || e.is_request()) {
return AgentSessionError::Start("endpoint OpenAI-compatible indisponible".to_owned());
}
if e.is_timeout() {
return AgentSessionError::Io(format!("communication endpoint OpenAI-compatible: {e}"));
}
AgentSessionError::Io(format!("communication endpoint OpenAI-compatible: {e}"))
}
fn persist_transcript(&self) -> Result<(), AgentSessionError> {
if let Some(parent) = self.transcript_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| AgentSessionError::Io(format!("création run dir: {e}")))?;
}
let transcript = self.transcript.lock().expect("mutex sain");
let bytes = serde_json::to_vec_pretty(&*transcript)
.map_err(|e| AgentSessionError::Decode(format!("transcript JSON invalide: {e}")))?;
std::fs::write(&self.transcript_path, bytes)
.map_err(|e| AgentSessionError::Io(format!("écriture transcript: {e}")))
}
}
#[async_trait]
impl AgentSession for OpenAiCompatibleSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
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(())
}
}
#[derive(Debug)]
struct TurnResult {
events: Vec<ReplyEvent>,
content: String,
reasoning_content: String,
tool_calls: Vec<OpenAiToolCall>,
}
impl TurnResult {
fn visible_content(&self) -> String {
if self.content.trim().is_empty() {
self.reasoning_content.clone()
} else {
self.content.clone()
}
}
}
fn handle_sse_line(
line: &str,
state: &mut StreamState,
events: &mut Vec<ReplyEvent>,
tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Result<(), AgentSessionError> {
let Some(data) = line.strip_prefix("data:") else {
return Ok(());
};
let data = data.trim();
if data == "[DONE]" {
return Ok(());
}
let parsed = parse_chat_delta(data)?;
let has_text = !parsed.text.is_empty();
if has_text {
state.content.push_str(&parsed.text);
let event = ReplyEvent::TextDelta { text: parsed.text };
send_tap(tap, &event);
events.push(event);
}
if !has_text && !parsed.reasoning_content.is_empty() {
state.reasoning_content.push_str(&parsed.reasoning_content);
let event = ReplyEvent::TextDelta {
text: parsed.reasoning_content,
};
send_tap(tap, &event);
events.push(event);
} else if !parsed.reasoning_content.is_empty() {
state.reasoning_content.push_str(&parsed.reasoning_content);
}
merge_tool_call_deltas(state, parsed.tool_calls);
Ok(())
}
fn merge_tool_call_deltas(state: &mut StreamState, calls: Vec<OpenAiToolCall>) {
for (idx, call) in calls.into_iter().enumerate() {
if state.tool_calls.len() <= idx {
state
.tool_calls
.resize_with(idx + 1, ToolCallBuilder::default);
}
let target = &mut state.tool_calls[idx];
if !call.id.is_empty() {
target.id = call.id;
}
if !call.name.is_empty() {
target.name = call.name;
}
target.arguments.push_str(&call.arguments);
}
}
fn text_events(
content: &str,
tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Vec<ReplyEvent> {
if content.is_empty() {
return Vec::new();
}
let event = ReplyEvent::TextDelta {
text: content.to_owned(),
};
send_tap(tap, &event);
vec![event]
}
fn send_tap(tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>, event: &ReplyEvent) {
if let Some(tap) = tap {
let _ = tap.send(event.clone());
}
}
fn local_tool_progress(
stage: ReplyProgressStage,
label: impl Into<String>,
tool_name: &str,
preview: Option<&str>,
) -> ReplyEvent {
let mut progress = ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
stage,
label,
)
.with_tool_name(tool_name.to_owned());
if let Some(preview) = preview {
progress = progress.with_text(preview);
}
ReplyEvent::Progress { progress }
}
fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool {
using_tools && matches!(err, AgentSessionError::Start(_))
}
fn tool_spec_to_openai(spec: &ToolSpec) -> Value {
json!({
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.input_schema,
}
})
}
fn chat_completions_endpoint(endpoint: &str) -> String {
let trimmed = endpoint.trim_end_matches('/');
if trimmed.ends_with("/chat/completions") {
trimmed.to_owned()
} else {
format!("{trimmed}/chat/completions")
}
}
fn load_or_seed_transcript(
path: &Path,
system_context: &str,
) -> Result<Vec<Value>, AgentSessionError> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| AgentSessionError::Decode(format!("transcript JSON illisible: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![json!({
"role": "system",
"content": system_context,
})]),
Err(e) => Err(AgentSessionError::Io(format!("lecture transcript: {e}"))),
}
}
#[allow(dead_code)]
fn _assert_adapter_name(adapter: StructuredAdapter) -> &'static str {
adapter.provider_key()
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct _SerdeGuard;
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use super::*;
use domain::ports::ToolInvocationError;
#[derive(Default)]
struct FakeInvoker {
calls: AtomicUsize,
tools: Mutex<Vec<ToolSpec>>,
bound_tools: Mutex<Option<Vec<ToolSpec>>>,
}
impl FakeInvoker {
fn with_bound_tools(tools: Vec<ToolSpec>) -> Self {
Self {
calls: AtomicUsize::new(0),
tools: Mutex::new(Vec::new()),
bound_tools: Mutex::new(Some(tools)),
}
}
}
#[async_trait]
impl ToolInvoker for FakeInvoker {
fn tools(&self) -> Vec<ToolSpec> {
let tools = self.tools.lock().expect("mutex sain");
if !tools.is_empty() {
return tools.clone();
}
vec![ToolSpec {
name: "idea_echo".to_owned(),
description: "Echo".to_owned(),
input_schema: json!({"type":"object"}),
}]
}
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
Ok(self
.bound_tools
.lock()
.expect("mutex sain")
.clone()
.unwrap_or_else(|| self.tools()))
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(format!("{name}:{args_json}"))
}
}
enum TestHttpResponse {
OkJson(&'static str),
Status { code: u16, body: &'static str },
Hang,
}
impl TestHttpResponse {
fn ok(body: &'static str) -> Self {
Self::OkJson(body)
}
}
async fn http_server(
responses: Vec<TestHttpResponse>,
) -> (String, Arc<Mutex<Vec<String>>>, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
let bodies = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&bodies);
let responses = Arc::new(Mutex::new(responses.into_iter()));
let handle = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let n = socket.read(&mut chunk).await.expect("read request");
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
if let Some(headers_end) = find_headers_end(&buffer) {
let headers = String::from_utf8_lossy(&buffer[..headers_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
let total = headers_end + 4 + content_length;
while buffer.len() < total {
let n = socket.read(&mut chunk).await.expect("read body");
if n == 0 {
return;
}
buffer.extend_from_slice(&chunk[..n]);
}
let body =
String::from_utf8_lossy(&buffer[headers_end + 4..total]).to_string();
captured.lock().expect("mutex sain").push(body);
break;
}
}
let body = responses
.lock()
.expect("mutex sain")
.next()
.unwrap_or_else(|| {
TestHttpResponse::OkJson(
r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#,
)
});
match body {
TestHttpResponse::OkJson(body) => {
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
socket
.write_all(response.as_bytes())
.await
.expect("write response");
}
TestHttpResponse::Status { code, body } => {
let response = format!(
"HTTP/1.1 {code} test\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
socket
.write_all(response.as_bytes())
.await
.expect("write response");
}
TestHttpResponse::Hang => {
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
}
});
(format!("http://{addr}/v1"), bodies, handle)
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn temp_run_dir(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"idea-openai-compat-{name}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&path).expect("temp run dir");
path
}
fn tool(name: &str) -> ToolSpec {
ToolSpec {
name: name.to_owned(),
description: format!("{name} description"),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}
}
fn config(endpoint: String, max_tool_iterations: Option<u16>) -> HttpChatConfig {
config_with_timeouts(endpoint, max_tool_iterations, 10_000, 1_000)
}
fn config_with_timeouts(
endpoint: String,
max_tool_iterations: Option<u16>,
request_timeout_ms: u32,
connect_timeout_ms: u32,
) -> HttpChatConfig {
HttpChatConfig::new(
endpoint,
"local-model",
None,
Some(request_timeout_ms),
Some(connect_timeout_ms),
max_tool_iterations,
)
.expect("valid config")
}
async fn drain(session: &OpenAiCompatibleSession, prompt: &str) -> Vec<ReplyEvent> {
session
.send(prompt)
.await
.expect("send")
.collect::<Vec<_>>()
}
#[test]
fn parse_openai_sse_delta_text() {
let parsed = parse_chat_delta(
r#"{"choices":[{"delta":{"content":"bonjour"},"finish_reason":null}]}"#,
)
.expect("parse ok");
assert_eq!(parsed.text, "bonjour");
assert!(parsed.reasoning_content.is_empty());
assert!(parsed.tool_calls.is_empty());
}
#[test]
fn parse_openai_sse_delta_reasoning_content() {
let parsed = parse_chat_delta(
r#"{"choices":[{"delta":{"reasoning_content":"je raisonne"},"finish_reason":null}]}"#,
)
.expect("parse ok");
assert!(parsed.text.is_empty());
assert_eq!(parsed.reasoning_content, "je raisonne");
}
#[test]
fn parse_ollama_completion_message() {
let parsed =
parse_completion(r#"{"choices":[{"message":{"role":"assistant","content":"salut"}}]}"#)
.expect("parse ok");
assert_eq!(parsed.content, "salut");
assert!(parsed.reasoning_content.is_empty());
}
#[test]
fn parse_completion_reasoning_content() {
let parsed = parse_completion(
r#"{"choices":[{"message":{"role":"assistant","content":"","reasoning_content":"raisonnement visible"}}]}"#,
)
.expect("parse ok");
assert!(parsed.content.is_empty());
assert_eq!(parsed.reasoning_content, "raisonnement visible");
assert_eq!(parsed.visible_content(), "raisonnement visible");
}
#[test]
fn sse_done_without_content_leaves_empty_visible_content_for_chat_error_mapping() {
let mut state = StreamState::default();
let mut events = Vec::new();
handle_sse_line("data: [DONE]", &mut state, &mut events, &None).expect("handle done");
let turn = TurnResult {
events,
content: state.content,
reasoning_content: state.reasoning_content,
tool_calls: Vec::new(),
};
assert!(turn.events.is_empty());
assert!(
turn.visible_content().is_empty(),
"chat bridge maps this empty final to ReplyChunk::Error"
);
}
#[test]
fn sse_reasoning_content_is_surfaced_when_content_is_empty_without_http() {
let mut state = StreamState::default();
let mut events = Vec::new();
handle_sse_line(
r#"data: {"choices":[{"delta":{"reasoning_content":"raison"},"finish_reason":null}]}"#,
&mut state,
&mut events,
&None,
)
.expect("handle reasoning");
let turn = TurnResult {
events,
content: state.content,
reasoning_content: state.reasoning_content,
tool_calls: Vec::new(),
};
assert!(matches!(
turn.events.as_slice(),
[ReplyEvent::TextDelta { text }] if text == "raison"
));
assert_eq!(turn.visible_content(), "raison");
}
#[test]
fn parse_completion_tool_calls() {
let parsed = parse_completion(
r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_ask_agent","arguments":"{\"target\":\"QA\"}"}}]}}]}"#,
)
.expect("parse ok");
assert_eq!(
parsed.tool_calls,
vec![OpenAiToolCall {
id: "call_1".to_owned(),
name: "idea_ask_agent".to_owned(),
arguments: "{\"target\":\"QA\"}".to_owned(),
}]
);
}
#[test]
fn parse_broken_json_does_not_leak_payload() {
let err = parse_chat_delta("{ not json").expect_err("decode error");
match err {
AgentSessionError::Decode(msg) => assert!(!msg.contains("not json")),
other => panic!("expected Decode, got {other:?}"),
}
}
#[test]
fn endpoint_appends_chat_completions_once() {
assert_eq!(
chat_completions_endpoint("http://localhost:11434/v1"),
"http://localhost:11434/v1/chat/completions"
);
assert_eq!(
chat_completions_endpoint("http://x/v1/chat/completions"),
"http://x/v1/chat/completions"
);
}
#[tokio::test]
async fn non_streaming_completion_persists_and_reloads_transcript() {
let (endpoint, bodies, handle) = http_server(vec![
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"one"}}]}"#,
),
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"two"}}]}"#,
),
])
.await;
let run_dir = temp_run_dir("persist");
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint.clone(), None),
&run_dir,
"# system",
None,
)
.expect("session");
let events = drain(&session, "hello").await;
assert!(matches!(events.last(), Some(ReplyEvent::Final { content }) if content == "one"));
assert!(run_dir.join(TRANSCRIPT_FILE).exists());
let reloaded = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, None),
&run_dir,
"# ignored",
None,
)
.expect("reloaded");
let _ = drain(&reloaded, "again").await;
let bodies = bodies.lock().expect("mutex sain");
assert!(
bodies[1].contains("one"),
"second request should include persisted assistant transcript: {}",
bodies[1]
);
handle.abort();
}
#[tokio::test]
async fn tool_loop_invokes_tool_and_reposts_result() {
let (endpoint, bodies, handle) = http_server(vec![
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{\"x\":1}"}}]}}]}"#,
),
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#,
),
])
.await;
let invoker = Arc::new(FakeInvoker::default());
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, Some(4)),
temp_run_dir("tools"),
"# system",
Some(invoker.clone()),
)
.expect("session");
let events = drain(&session, "use tool").await;
assert_eq!(invoker.calls.load(Ordering::SeqCst), 1);
assert!(events.iter().any(|event| matches!(
event,
ReplyEvent::ToolActivity { label } if label == "idea_echo"
)));
assert!(matches!(events.last(), Some(ReplyEvent::Final { content }) if content == "done"));
let bodies = bodies.lock().expect("mutex sain");
assert_eq!(bodies.len(), 2);
assert!(bodies[1].contains("\"role\":\"tool\""));
handle.abort();
}
#[tokio::test]
async fn first_openai_request_uses_bound_tool_surface() {
let (endpoint, bodies, handle) = http_server(vec![TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#,
)])
.await;
let invoker = Arc::new(FakeInvoker::with_bound_tools(vec![
tool("idea_memory_read"),
tool("idea_run_in_background"),
]));
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, Some(1)),
temp_run_dir("initial-bound-tools"),
"# system",
Some(invoker),
)
.expect("session");
let _ = drain(&session, "hello").await;
let bodies = bodies.lock().expect("mutex sain");
let body: Value = serde_json::from_str(&bodies[0]).expect("request body JSON");
let names = body["tools"]
.as_array()
.expect("initial request carries tools")
.iter()
.map(|tool| {
tool["function"]["name"]
.as_str()
.expect("tool function name")
.to_owned()
})
.collect::<Vec<_>>();
assert_eq!(names, vec!["idea_memory_read", "idea_run_in_background"]);
handle.abort();
}
#[tokio::test]
async fn max_tool_iterations_returns_single_degraded_final() {
let tool = r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{}"}}]}}]}"#;
let (endpoint, _bodies, handle) =
http_server(vec![TestHttpResponse::ok(tool), TestHttpResponse::ok(tool)]).await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, Some(1)),
temp_run_dir("limit"),
"# system",
Some(Arc::new(FakeInvoker::default())),
)
.expect("session");
let events = drain(&session, "loop").await;
let finals = events
.iter()
.filter(|event| matches!(event, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 1);
assert!(matches!(
events.last(),
Some(ReplyEvent::Final { content }) if content.contains("max_tool_iterations")
));
handle.abort();
}
#[tokio::test]
async fn normal_completion_returns_exactly_one_terminal_final() {
let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#,
)])
.await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, None),
temp_run_dir("single-final"),
"# system",
None,
)
.expect("session");
let events = drain(&session, "hello").await;
let final_positions: Vec<usize> = events
.iter()
.enumerate()
.filter_map(|(idx, event)| matches!(event, ReplyEvent::Final { .. }).then_some(idx))
.collect();
assert_eq!(final_positions, vec![events.len() - 1]);
handle.abort();
}
#[tokio::test]
async fn openai_compatible_conversation_id_is_none() {
let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#,
)])
.await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, None),
temp_run_dir("conversation-id-none"),
"# system",
None,
)
.expect("session");
assert_eq!(session.conversation_id(), None);
let _ = drain(&session, "hello").await;
assert_eq!(session.conversation_id(), None);
handle.abort();
}
#[tokio::test]
async fn unreachable_endpoint_on_first_contact_is_start_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config_with_timeouts(format!("http://{addr}/v1"), None, 500, 100),
temp_run_dir("unreachable"),
"# system",
None,
)
.expect("session");
match session.send("hello").await {
Err(AgentSessionError::Start(msg)) => {
assert!(msg.contains("indisponible"), "unexpected message: {msg}");
}
Err(other) => panic!("expected Start, got {other:?}"),
Ok(_) => panic!("expected first contact failure"),
}
}
#[tokio::test]
async fn timeout_after_successful_contact_is_io_error() {
let (endpoint, _bodies, handle) = http_server(vec![
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"one"}}]}"#,
),
TestHttpResponse::Hang,
])
.await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config_with_timeouts(endpoint, None, 100, 100),
temp_run_dir("mid-turn-timeout"),
"# system",
None,
)
.expect("session");
let _ = drain(&session, "first").await;
match session.send("second").await {
Err(AgentSessionError::Io(msg)) => {
assert!(
msg.contains("communication endpoint OpenAI-compatible"),
"unexpected message: {msg}"
);
}
Err(other) => panic!("expected Io, got {other:?}"),
Ok(_) => panic!("expected timeout after first contact"),
}
handle.abort();
}
#[tokio::test]
async fn http_400_404_422_map_to_start_without_body_leak() {
for status in [400, 404, 422] {
let secret_body = r#"{"error":"MODEL_SECRET_SHOULD_NOT_LEAK"}"#;
let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::Status {
code: status,
body: secret_body,
}])
.await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, None),
temp_run_dir(&format!("status-{status}")),
"# system",
None,
)
.expect("session");
match session.send("hello").await {
Err(AgentSessionError::Start(msg)) => {
assert!(msg.contains(&format!("HTTP {status}")));
assert!(
!msg.contains("MODEL_SECRET_SHOULD_NOT_LEAK") && !msg.contains("error"),
"raw HTTP body must not leak in error message: {msg}"
);
}
Err(other) => panic!("expected Start for HTTP {status}, got {other:?}"),
Ok(_) => panic!("expected HTTP {status} error"),
}
handle.abort();
}
}
#[tokio::test]
async fn tool_rejection_disables_tools_retries_once_and_keeps_conversing() {
let (endpoint, bodies, handle) = http_server(vec![
TestHttpResponse::Status {
code: 400,
body: r#"{"error":"tools unsupported"}"#,
},
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"chat nu"}}]}"#,
),
TestHttpResponse::ok(
r#"{"choices":[{"message":{"role":"assistant","content":"encore"}}]}"#,
),
])
.await;
let session = OpenAiCompatibleSession::new(
SessionId::new_random(),
config(endpoint, None),
temp_run_dir("tools-rejected"),
"# system",
Some(Arc::new(FakeInvoker::default())),
)
.expect("session");
let first = drain(&session, "hello").await;
assert_eq!(
first
.iter()
.filter(|event| matches!(event, ReplyEvent::Announcement { .. }))
.count(),
1,
"tool rejection should announce the degradation exactly once"
);
assert!(matches!(
first.last(),
Some(ReplyEvent::Final { content }) if content == "chat nu"
));
let second = drain(&session, "again").await;
assert_eq!(
second
.iter()
.filter(|event| matches!(event, ReplyEvent::Announcement { .. }))
.count(),
0,
"tools stay disabled; no repeated degradation announcement"
);
assert!(matches!(
second.last(),
Some(ReplyEvent::Final { content }) if content == "encore"
));
let bodies = bodies.lock().expect("mutex sain");
assert_eq!(bodies.len(), 3);
assert!(bodies[0].contains("\"tools\""), "first request uses tools");
assert!(
!bodies[1].contains("\"tools\"") && !bodies[2].contains("\"tools\""),
"retried and later requests must be plain chat: {bodies:?}"
);
handle.abort();
}
}