Lot 6 : un tour de discussion de bout en bout en Rust (chat::generate)

Port d'EnqueueOrStart, StartTurn, generate, drainQueued,
startQueuedIfAny, dropQueued et compactAndPublish :
- envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis
  en file si un tour tourne, sinon moteur prêt (/health), verrou du tour
  puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ;
- tour : compaction de début de tour, vue d'envoi (tête du Go, historique
  sans <context_update>, préambule fusionné comme InjectSkills), boucle
  dont chaque événement devient un delta du journal, historique rangé
  (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés),
  contexte estimé si le moteur ne le compte pas, compaction de fin de tour ;
- fin : turn_done, journal coalescé, verrou et bail rendus, discussion
  enregistrée, file abandonnée après un stop ou message suivant lancé,
  sinon Web Push ;
- stop : le tour en cours, ou le détenteur Go du moteur.

chat::history porte historyFromView, dropStrayNudges, le rappel des pages
mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du
journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en
mémoire seulement. EngineLease::interrupted ne garde plus de verrou à
travers une attente.

Écarts : boucle de vérification du mode Code, critères pour la
compaction, préchauffage, télémétrie, retry_patterns personnalisés.

Tests de bout en bout : tour complet (outil, réponse, bail rendu,
discussion enregistrée, doublon), message en file injecté entre deux
étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 11:00:17 +00:00
1 parent f2d2669690
commit b5649eb506
11 files changed
+1678 -10

No files matched your search

+30
View File
@@ -42,6 +42,8 @@ pub struct FakeGo {
pub recall: Mutex<Vec<String>>,
/// Discussion active annoncée et discussions supprimées.
pub conv_log: Mutex<Vec<String>>,
/// Tours : débuts, vues et fins demandés.
pub turn_log: Mutex<Vec<String>>,
/// Adresse d'écoute du faux Go (pour un client `Store` direct).
pub backend: Mutex<Option<SocketAddr>>,
}
@@ -191,6 +193,34 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
.push(format!("{what} {}", v["id"].as_str().unwrap_or_default()));
resp(204, "")
}
"/internal/v1/turn/start" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let msg = v["message"].as_str().unwrap_or_default().to_string();
st.turn_log.lock().unwrap().push(format!("start {msg}"));
if msg.trim().is_empty() {
return resp(400, r#"{"error":"message vide"}"#);
}
let caps = serde_json::json!({"Agent": true, "Mem": "off", "Code": false, "Role": ""});
let out = serde_json::json!({
"files": [], "caps": caps, "turn_caps": caps,
"content": msg, "model": "m1", "code_hint": false,
});
resp(200, out.to_string())
}
"/internal/v1/turn/view" => {
st.turn_log.lock().unwrap().push("view".into());
let out = serde_json::json!({
"head": [{"role": "system", "content": "Tu es Loki."}],
"prefix": "PRÉAMBULE",
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
});
resp(200, out.to_string())
}
"/internal/v1/turn/done" => {
st.turn_log.lock().unwrap().push("done".into());
resp(204, "")
}
"/internal/v1/engine/stop" => {
st.lease_log.lock().unwrap().push("stop".into());
*st.go_busy.lock().unwrap() = None;
+231 -6
View File
@@ -443,10 +443,21 @@ use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TurnReaso
async fn fake_engine(
replies: Vec<String>,
) -> (String, Arc<StdMutex<Vec<(String, serde_json::Value)>>>) {
fake_engine_gated(replies, None).await
}
type Seen = Arc<StdMutex<Vec<(String, serde_json::Value)>>>;
/// Comme [`fake_engine`] ; avec `gate`, chaque complétion attend un jeton.
/// `/health` répond toujours 200.
async fn fake_engine_gated(
replies: Vec<String>,
gate: Option<Arc<tokio::sync::Semaphore>>,
) -> (String, Seen) {
use http_body_util::{BodyExt, Full};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = Arc::new(StdMutex::new(Vec::new()));
let seen: Seen = Arc::new(StdMutex::new(Vec::new()));
let replies = Arc::new(StdMutex::new(std::collections::VecDeque::from(replies)));
let s = seen.clone();
tokio::spawn(async move {
@@ -454,12 +465,20 @@ async fn fake_engine(
let Ok((stream, _)) = listener.accept().await else {
return;
};
let (s, replies) = (s.clone(), replies.clone());
let (s, replies, gate) = (s.clone(), replies.clone(), gate.clone());
tokio::spawn(async move {
let svc = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
let (s, replies) = (s.clone(), replies.clone());
let (s, replies, gate) = (s.clone(), replies.clone(), gate.clone());
async move {
let ok = |b: String| {
Ok::<_, std::convert::Infallible>(hyper::Response::new(Full::new(
bytes::Bytes::from(b),
)))
};
if req.uri().path() == "/health" {
return ok("ok".into());
}
let auth = req
.headers()
.get("authorization")
@@ -469,10 +488,11 @@ async fn fake_engine(
s.lock()
.unwrap()
.push((auth, serde_json::from_slice(&body).unwrap()));
if let Some(g) = &gate {
g.acquire().await.unwrap().forget();
}
let out = replies.lock().unwrap().pop_front().unwrap_or_default();
Ok::<_, std::convert::Infallible>(hyper::Response::new(Full::new(
bytes::Bytes::from(out),
)))
ok(out)
}
},
);
@@ -647,3 +667,208 @@ async fn crochets_go_des_discussions() {
]
);
}
// ---- un tour de bout en bout, par le service ----
use loki_rs::chat::generate::{ChatService, ERR_MODEL_LOADING, Refused, Sent};
use loki_rs::chat::sessions::GoSessionHooks;
fn port_of(url: &str) -> String {
url.trim_start_matches("http://127.0.0.1:")
.split('/')
.next()
.unwrap()
.to_string()
}
async fn service(
engine_url: Option<&str>,
) -> (
Arc<ChatService>,
Arc<fakego::FakeGo>,
tokio::sync::oneshot::Sender<()>,
tempfile::TempDir,
) {
let (st, go, stop, t) = store().await;
go.set(
"config",
"PORT",
&engine_url.map(port_of).unwrap_or_else(|| "1".into()),
);
go.set("state", "api_key", "cle-du-moteur");
let sessions = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
sessions.load().await.unwrap();
let shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
(
ChatService::new(Arc::new(sessions), Arc::new(shared)),
go,
stop,
t,
)
}
async fn until_done(svc: &ChatService, turns: usize) {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let c = svc.sessions.conv();
let done = c.with_state(|st| {
st.log
.iter()
.filter(|e| e.delta.contains_key("turn_done"))
.count()
});
if done >= turns && !c.is_generating() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("tour jamais terminé");
}
fn deltas(svc: &ChatService, key: &str) -> Vec<serde_json::Value> {
svc.sessions.conv().with_state(|st| {
st.log
.iter()
.filter_map(|e| e.delta.get(key).cloned())
.collect()
})
}
fn tool_then_answer(answer: &str) -> Vec<String> {
vec![
sse(&[
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
]),
sse(&[
serde_json::json!({"choices":[{"delta":{"content": answer}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":80,"completion_tokens":3}}),
]),
]
}
#[tokio::test]
async fn tour_de_bout_en_bout() {
let (url, seen) = fake_engine(tool_then_answer("fini")).await;
let (svc, go, _stop, _t) = service(Some(&url)).await;
let sent = svc
.send(serde_json::json!({"message": "liste", "cid": "a1"}))
.await
.unwrap();
assert_eq!(sent, Sent::default());
until_done(&svc, 1).await;
// La fin est signalée en tout dernier (après l'enregistrement) : on l'attend.
until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await;
// Vue modèle rangée : la demande, l'appel, son résultat, la réponse.
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
let roles: Vec<&str> = msgs.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, ["user", "assistant", "tool", "assistant"]);
assert_eq!(msgs[3].content, Some(serde_json::json!("fini")));
// Journal : bulle avec le modèle, outil, réponse, fin.
assert_eq!(deltas(&svc, "user"), [serde_json::json!("liste")]);
assert_eq!(deltas(&svc, "model"), [serde_json::json!("m1")]);
assert!(
deltas(&svc, "tool_used")
.iter()
.any(|t| t["done"] == true && t["result"] == "exec:bash")
);
assert_eq!(deltas(&svc, "content"), [serde_json::json!("fini")]);
assert!(!deltas(&svc, "stats").is_empty());
// Requête : préambule fusionné au prompt du preset, clé du moteur, outils.
let seen = seen.lock().unwrap().clone();
assert_eq!(seen[0].0, "Bearer cle-du-moteur");
assert_eq!(
seen[0].1["messages"][0]["content"],
"PRÉAMBULE\n\nTu es Loki."
);
assert_eq!(seen[0].1["messages"][1]["content"], "liste");
assert_eq!(seen[0].1["tools"][0]["function"]["name"], "bash");
// Bail rendu, fin signalée, discussion enregistrée.
assert_eq!(*go.lease.lock().unwrap(), None);
let id = svc.sessions.conv().id();
let body = opened(&go, BK_CHAT, &format!("conv:{id}")).unwrap();
assert_eq!(decode_state(&body).messages.len(), 4);
// Réessai du même envoi : reconnu.
let again = svc
.send(serde_json::json!({"message": "liste", "cid": "a1"}))
.await
.unwrap();
assert!(again.dup);
}
#[tokio::test]
async fn message_en_file_injecte_dans_le_tour() {
let gate = Arc::new(tokio::sync::Semaphore::new(0));
let (url, seen) = fake_engine_gated(tool_then_answer("les deux"), Some(gate.clone())).await;
let (svc, _go, _stop, _t) = service(Some(&url)).await;
svc.send(serde_json::json!({"message": "premier", "cid": "p1"}))
.await
.unwrap();
until(|| seen.lock().unwrap().len() == 1).await;
let q = svc
.send(serde_json::json!({"message": "deuxième", "cid": "p2"}))
.await
.unwrap();
assert!(q.queued, "un tour tourne : en file");
gate.add_permits(10);
until_done(&svc, 1).await;
assert_eq!(
deltas(&svc, "user"),
[serde_json::json!("premier"), serde_json::json!("deuxième")]
);
let second = seen.lock().unwrap()[1].1.clone();
let last = second["messages"]
.as_array()
.unwrap()
.last()
.unwrap()
.clone();
assert_eq!(last["content"], "deuxième", "injecté entre deux étapes");
let users = svc
.sessions
.conv()
.with_state(|st| st.messages.iter().filter(|m| m.role == "user").count());
assert_eq!(users, 2);
}
#[tokio::test]
async fn moteur_tenu_par_une_tache_planifiee() {
let (url, _seen) = fake_engine(vec![]).await;
let (svc, go, _stop, _t) = service(Some(&url)).await;
*go.go_busy.lock().unwrap() = Some("la tâche planifiée « veille » occupe le modèle".into());
let body = serde_json::json!({"message": "salut", "cid": "b1"});
let want = Refused {
status: 409,
error: "la tâche planifiée « veille » occupe le modèle".into(),
};
assert_eq!(svc.send(body.clone()).await, Err(want.clone()));
// Refusé : le même envoi peut être renvoyé (pas pris pour un doublon).
assert_eq!(svc.send(body).await, Err(want));
assert!(!svc.sessions.conv().is_generating());
assert!(deltas(&svc, "user").is_empty());
// Le stop du chat arrête le détenteur Go.
svc.stop().await;
assert!(go.lease_log.lock().unwrap().contains(&"stop".to_string()));
}
#[tokio::test]
async fn moteur_pas_pret_et_message_vide() {
let (svc, _go, _stop, _t) = service(None).await;
let r = svc
.send(serde_json::json!({"message": "salut", "cid": "c1"}))
.await;
assert_eq!(
r,
Err(Refused {
status: 503,
error: ERR_MODEL_LOADING.into()
})
);
let r = svc
.send(serde_json::json!({"message": " ", "cid": "c2"}))
.await;
assert_eq!(r.unwrap_err().status, 400);
}