mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Façade : changement de projet sans écraser la discussion, détenteur décidé par le Go
Avec LOKI_CHAT=rust, changer de projet passait par projectSwitch du Go, qui enregistrait SA copie de la discussion — chargée au démarrage, périmée — par-dessus celle de la façade, ouvrait une autre discussion que la façade ignorait, et ne voyait pas un tour de la façade en cours. Désormais : - /api/projects (switch) remet le changement à la façade (/internal/facade/v1/project/switch) : elle enregistre sa discussion avec le projet quitté, fait changer le projet actif au Go (/internal/v1/project/activate), ouvre la plus récente du nouveau projet (une neuve s'il n'en a pas) ; refusé pendant un tour (409) ; - supprimer un projet : la façade le quitte d'abord s'il est actif, puis supprime ses discussions (/internal/facade/v1/project/drop). Réglages que la façade ne sait pas honorer (PROJ_SNAPSHOT, KEEP_TURN_IMAGES, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs retry_patterns) : le Go garde la conversation, décidé une fois au démarrage (facadeChatBlocker) ; la façade le lit (/internal/v1/chat/holder) avant de servir quoi que ce soit, au lieu d'échouer en plein tour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
14 files changed
+655
-20
No files matched your search
@@ -54,6 +54,8 @@ pub struct FakeGo {
|
||||
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
|
||||
pub code: Mutex<bool>,
|
||||
pub tool_log: Mutex<Vec<String>>,
|
||||
/// Le Go garde la conversation (raison), sinon la façade la tient.
|
||||
pub go_keeps_chat: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -234,6 +236,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
|
||||
None => resp(200, r#"{"holder":"rust"}"#),
|
||||
Some(why) => resp(
|
||||
200,
|
||||
serde_json::json!({"holder": "go", "reason": why}).to_string(),
|
||||
),
|
||||
},
|
||||
"/internal/v1/project/activate" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let slug = v["slug"].as_str().unwrap_or_default().to_string();
|
||||
if slug.is_empty() || slug == "absent" {
|
||||
return resp(400, r#"{"error":"projet introuvable"}"#);
|
||||
}
|
||||
st.conv_log.lock().unwrap().push(format!("project {slug}"));
|
||||
st.set("state", "active_project", &slug);
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/turn/verifier" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
|
||||
@@ -47,8 +47,8 @@ async fn outil_execute_par_le_go() {
|
||||
// ---- persistance des discussions par l'API interne ----
|
||||
|
||||
use loki_rs::chat::persist::{
|
||||
BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, Persister, StoreSink, ToolResJob,
|
||||
decode_index, decode_state,
|
||||
BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, DEFAULT_PROJECT_SLUG, Persister,
|
||||
StoreSink, ToolResJob, decode_index, decode_state,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -281,7 +281,7 @@ async fn sessions() -> (
|
||||
)
|
||||
}
|
||||
|
||||
fn dire(s: &Sessions<Crochets>, texte: &str) {
|
||||
fn dire<H: SessionHooks>(s: &Sessions<H>, texte: &str) {
|
||||
let c = s.conv();
|
||||
c.append_delta(
|
||||
c.epoch(),
|
||||
@@ -296,7 +296,7 @@ fn dire(s: &Sessions<Crochets>, texte: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
fn textes(s: &Sessions<Crochets>) -> Vec<String> {
|
||||
fn textes<H: SessionHooks>(s: &Sessions<H>) -> Vec<String> {
|
||||
s.conv().with_state(|st| {
|
||||
st.messages
|
||||
.iter()
|
||||
@@ -397,6 +397,68 @@ async fn creer_basculer_renommer() {
|
||||
assert_eq!(s.rename(&a, " ").await, Err(SessionError::Missing));
|
||||
}
|
||||
|
||||
/// Qui tient la conversation : le Go en décide (réglage qu'il garde).
|
||||
#[tokio::test]
|
||||
async fn detenteur_de_la_conversation() {
|
||||
let (st, go, _stop, _t) = store().await;
|
||||
assert_eq!(loki_rs::chat::generate::go_keeps_chat(&st).await, Ok(None));
|
||||
*go.go_keeps_chat.lock().unwrap() = Some("PROJ_SNAPSHOT".into());
|
||||
assert_eq!(
|
||||
loki_rs::chat::generate::go_keeps_chat(&st).await,
|
||||
Ok(Some("PROJ_SNAPSHOT".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Changer de projet : la discussion quittée est rangée avec SON projet, la
|
||||
/// plus récente du nouveau s'ouvre (une neuve s'il n'en a pas) ; supprimer un
|
||||
/// projet emporte ses discussions.
|
||||
#[tokio::test]
|
||||
async fn changer_de_projet_et_en_supprimer_un() {
|
||||
let (st, go, _stop, _t) = store().await;
|
||||
let s = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
|
||||
s.load().await.unwrap();
|
||||
let a = s.conv().id();
|
||||
dire(&s, "dans le projet par défaut");
|
||||
s.project_switch("deux").await.unwrap();
|
||||
assert_eq!(go.get("state", "active_project").unwrap(), "deux");
|
||||
let b = s.conv().id();
|
||||
assert_ne!(a, b);
|
||||
assert!(textes(&s).is_empty());
|
||||
let idx = s.read_index().await;
|
||||
let proj = |id: &str| idx.iter().find(|m| m.id == id).unwrap().project.clone();
|
||||
assert_eq!(proj(&a), DEFAULT_PROJECT_SLUG);
|
||||
assert_eq!(proj(&b), "deux");
|
||||
let body = opened(&go, BK_CHAT, &format!("conv:{a}")).unwrap();
|
||||
assert_eq!(decode_state(&body).messages.len(), 1);
|
||||
dire(&s, "dans deux");
|
||||
s.project_switch(DEFAULT_PROJECT_SLUG).await.unwrap();
|
||||
assert_eq!(s.conv().id(), a);
|
||||
assert_eq!(textes(&s), ["dans le projet par défaut"]);
|
||||
// Même projet : rien ; projet refusé par le Go : rien ne change.
|
||||
s.project_switch(DEFAULT_PROJECT_SLUG).await.unwrap();
|
||||
assert!(matches!(
|
||||
s.project_switch("absent").await,
|
||||
Err(SessionError::Store(_))
|
||||
));
|
||||
assert_eq!(s.project_switch(" ").await, Err(SessionError::Missing));
|
||||
assert_eq!(s.conv().id(), a);
|
||||
assert_eq!(
|
||||
go.get("state", "active_project").unwrap(),
|
||||
DEFAULT_PROJECT_SLUG
|
||||
);
|
||||
// Les discussions de « deux » partent : contenu, index, nettoyage du Go.
|
||||
assert_eq!(s.drop_project("deux").await.unwrap(), 1);
|
||||
assert!(s.read_index().await.iter().all(|m| m.id != b));
|
||||
assert!(go.get(BK_CHAT, &format!("conv:{b}")).is_none());
|
||||
assert!(
|
||||
go.conv_log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&format!("dropped {b}"))
|
||||
);
|
||||
assert_eq!(s.conv().id(), a);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supprimer_l_ouverte_puis_la_derniere() {
|
||||
let (s, h, go, _stop, _t) = sessions().await;
|
||||
@@ -1311,6 +1373,38 @@ async fn routes_du_chat_par_la_facade() {
|
||||
);
|
||||
assert!(!chat.sessions.conv().with_state(|st| st.log.is_empty()));
|
||||
|
||||
// Changement de projet remis par le Go : la discussion part avec son
|
||||
// projet, une neuve s'ouvre dans le nouveau.
|
||||
assert_eq!(
|
||||
call_internal(
|
||||
facade,
|
||||
"/internal/facade/v1/project/switch",
|
||||
r#"{"slug":"deux"}"#
|
||||
)
|
||||
.await,
|
||||
200
|
||||
);
|
||||
assert_ne!(chat.sessions.conv().id(), a);
|
||||
assert_eq!(
|
||||
call_internal(
|
||||
facade,
|
||||
"/internal/facade/v1/project/switch",
|
||||
r#"{"slug":""}"#
|
||||
)
|
||||
.await,
|
||||
400
|
||||
);
|
||||
assert_eq!(
|
||||
call_internal(
|
||||
facade,
|
||||
"/internal/facade/v1/project/switch",
|
||||
r#"{"slug":"generale"}"#
|
||||
)
|
||||
.await,
|
||||
200
|
||||
);
|
||||
assert_eq!(chat.sessions.conv().id(), a);
|
||||
|
||||
// Trafic remis par le Go (tunnel) : déjà authentifié par lui.
|
||||
go.set("state", "web_key", "cle-web");
|
||||
assert_eq!(call(facade, "GET", "/api/chat/state", "").await.0, 401);
|
||||
|
||||
Reference in new issue
Block a user