diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md new file mode 100644 index 0000000..4c9d862 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md @@ -0,0 +1,77 @@ +# Lot 38 — L'outil criteria du mode Code dans la façade + +## Contexte + +En mode Code, le modèle tient les critères d'acceptation de sa tâche avec +l'outil `criteria` : + +- les poser ; +- les marquer faits ; +- les lister ; +- les effacer. + +La passe de vérification les marque ensuite `passed` ou `failed`. + +Depuis le lot 19, la façade sert déjà les critères de l'écran +(`/api/chat/criteria`, `chat/crit:` scellés). La passe de vérification +les relit depuis le lot 7. Mais l'outil lui-même partait au Go +(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta +(`critNotify` → `/internal/facade/v1/delta`). + +## Le lot + +`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près : + +- **list** : action vide ou d'un autre type comprise ; +- **add** : + - textes vides ou d'un autre type ignorés ; + - ids repris après le plus grand ; + - vingt critères au plus ; +- **set** : + - id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ; + - statut libre, note rognée ; + - `passed` refusé hors de la passe de vérification ; +- **clear**. + +Les règles de rangement et d'affichage : + +- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme + dans le Go ; +- une liste rangée illisible vaut « aucun critère » ; +- la note vide n'est pas écrite (`omitempty`). + +`GoTurnHost::exec_tool` l'exécute pour la discussion du tour +(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à +pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta +`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte : + +- le journal de la discussion pour un tour ; +- celui de la conversation pour une passe de vérification. + +Écart assumé : le Go rangeait les critères sous la discussion **active** +au moment de l'appel (`convEnsureActive`). La façade les range sous celle du +tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant +la génération, et c'est alors la façade qui a raison. + +`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant +de l'encoder, ce qu'un port ne rendrait pas octet pour octet. + +## Parité + +`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels +(`tests/parity_tools/criteria.json`). Chaque étape porte : + +- les arguments, builder ou vérification ; +- le résultat ; +- les critères rangés ensuite. + +`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il +vérifie aussi : + +- le rangement scellé ; +- la liste poussée à l'écran à chaque changement, et seulement alors. + +`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai +contrat. La passe de vérification marque le critère `passed` dans la +façade : rien ne passe par le Go, le critère est rangé scellé, l'écran +reçoit la liste. diff --git a/internal/loki/code_criteria.go b/internal/loki/code_criteria.go index eb7830c..d1b7323 100644 --- a/internal/loki/code_criteria.go +++ b/internal/loki/code_criteria.go @@ -9,6 +9,10 @@ package loki // // Rangés par discussion dans bbolt (bucket chat, clé crit:) : ils suivent // la discussion, sa suppression les emporte. +// +// Port Rust de l'outil (lot 38) : rust/loki-rs/src/chat/crittool.rs, employé +// par les tours de la façade ; journal de parité dans +// parity_criteria_test.go. import ( "encoding/json" diff --git a/internal/loki/parity_criteria_test.go b/internal/loki/parity_criteria_test.go new file mode 100644 index 0000000..7ef54aa --- /dev/null +++ b/internal/loki/parity_criteria_test.go @@ -0,0 +1,111 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// parity_criteria_test.go — journal de référence de l'outil criteria du mode +// Code (lot 38 de la migration Rust) : chaque appel (arguments tels que le +// modèle les envoie, builder ou vérification), son résultat, et les critères +// rangés ensuite pour la discussion. Rangé dans +// rust/loki-rs/tests/parity_tools/criteria.json ; tests/tools.rs le rejoue +// par le port Rust. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteCriteres + +const parityCriteriaFile = "../../rust/loki-rs/tests/parity_tools/criteria.json" + +type parityCriteriaStep struct { + Name string `json:"name"` + Verifier bool `json:"verifier,omitempty"` + Put string `json:"put,omitempty"` // critères écrits tels quels avant l'appel + Args json.RawMessage `json:"args"` + Out string `json:"out"` + Stored any `json:"stored"` // la liste rangée (null : aucune) +} + +func TestPariteCriteres(t *testing.T) { + testHome(t) + conv := convEnsureActive() + var steps []parityCriteriaStep + run := func(st parityCriteriaStep) { + if st.Put != "" { + if err := putStoreBytes(bkChat, critKey(conv), []byte(st.Put)); err != nil { + t.Fatal(err) + } + } + var args map[string]any + if err := json.Unmarshal(st.Args, &args); err != nil { + t.Fatalf("%s : %v", st.Name, err) + } + if args == nil { + args = map[string]any{} + } + st.Out = toolCriteria(args, st.Verifier) + st.Stored = nil + if b, ok := getStoreBytes(bkChat, critKey(conv)); ok && len(b) > 0 { + var v any + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + st.Stored = v + } + steps = append(steps, st) + } + a := func(s string) json.RawMessage { return json.RawMessage(s) } + + run(parityCriteriaStep{Name: "liste vide", Args: a(`{"action":"list"}`)}) + run(parityCriteriaStep{Name: "action absente : liste", Args: a(`{}`)}) + run(parityCriteriaStep{Name: "action d'un autre type", Args: a(`{"action":3}`)}) + run(parityCriteriaStep{Name: "ajout sans textes", Args: a(`{"action":"add"}`)}) + run(parityCriteriaStep{Name: "ajout de textes vides ou d'un autre type", Args: a(`{"action":"add","texts":[" ",4,null]}`)}) + run(parityCriteriaStep{Name: "ajout", Args: a(`{"action":"add","texts":[" les tests passent ","la doc <à jour> & relue",""]}`)}) + run(parityCriteriaStep{Name: "ajout à la suite", Args: a(`{"action":"add","texts":["pas de régression"]}`)}) + run(parityCriteriaStep{Name: "le builder dit fait", Args: a(`{"action":"set","id":1,"status":"completed","note":" vérifié à la main "}`)}) + run(parityCriteriaStep{Name: "le builder ne valide pas", Args: a(`{"action":"set","id":1,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "id en chaîne avec #", Verifier: true, Args: a(`{"action":"set","id":" #2 ","status":"failed","note":"lien mort"}`)}) + run(parityCriteriaStep{Name: "id en chaîne illisible", Verifier: true, Args: a(`{"action":"set","id":"deux","status":"passed"}`)}) + run(parityCriteriaStep{Name: "id décimal tronqué", Verifier: true, Args: a(`{"action":"set","id":3.9,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "note seule", Args: a(`{"action":"set","id":"+1","note":"revu"}`)}) + run(parityCriteriaStep{Name: "statut libre", Args: a(`{"action":"set","id":2,"status":"bloqué"}`)}) + run(parityCriteriaStep{Name: "id inconnu", Verifier: true, Args: a(`{"action":"set","id":9,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "liste", Args: a(`{"action":"list"}`)}) + run(parityCriteriaStep{Name: "action inconnue", Args: a(`{"action":"reset"}`)}) + run(parityCriteriaStep{Name: "effacer", Args: a(`{"action":"clear"}`)}) + run(parityCriteriaStep{Name: "effacer à vide", Args: a(`{"action":"clear"}`)}) + // Vingt au plus ; les ids reprennent après le plus grand. + run(parityCriteriaStep{Name: "liste pleine", + Put: `[{"id":7,"text":"a","status":"passed"},{"id":3,"text":"b","status":"pending"}]`, + Args: a(`{"action":"add","texts":["c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w"]}`)}) + run(parityCriteriaStep{Name: "liste déjà pleine", Args: a(`{"action":"add","texts":["x"]}`)}) + run(parityCriteriaStep{Name: "liste rangée illisible", Put: `{"pas":"une liste"}`, Args: a(`{"action":"list"}`)}) + + got, err := json.MarshalIndent(steps, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityCriteriaFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityCriteriaFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityCriteriaFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("l'outil criteria ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityCriteriaFile) + } +} diff --git a/rust/loki-rs/src/chat/crittool.rs b/rust/loki-rs/src/chat/crittool.rs new file mode 100644 index 0000000..01c9696 --- /dev/null +++ b/rust/loki-rs/src/chat/crittool.rs @@ -0,0 +1,176 @@ +//! L'outil `criteria` du mode Code (lot 38) — port de `toolCriteria` +//! (`code_criteria.go`) : le modèle pose, met à jour, liste ou efface les +//! critères d'acceptation de la discussion (`chat/crit:`, scellés comme +//! le contenu des discussions). Seule la passe de vérification peut marquer +//! un critère `passed`. Chaque changement part aussi à l'écran (delta +//! `criteria`, `critNotify`). + +use serde_json::{Map, Value, json}; + +use super::persist::BK_CHAT; +use super::verify::{Criterion, crit_list, crit_render}; +use crate::store::Store; + +/// `critMaxCount`. +pub const CRIT_MAX_COUNT: usize = 20; + +/// L'issue d'un appel : le texte rendu au modèle, et la liste à pousser à +/// l'écran quand elle a changé (vide : effacée). +#[derive(Debug, Default, PartialEq)] +pub struct CritOut { + pub result: String, + pub notify: Option>, +} + +impl CritOut { + fn text(s: impl Into) -> Self { + CritOut { + result: s.into(), + notify: None, + } + } +} + +/// `strconv.Atoi` (signe accepté) ; 0 sinon, comme l'id ignoré du Go. +fn go_atoi(s: &str) -> i64 { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) { + return 0; + } + s.parse().unwrap_or(0) +} + +/// `strings.TrimSpace`. +fn go_trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +/// `critSave` : une liste vide supprime la clé ; une erreur est ignorée, +/// comme dans le Go. +async fn crit_save(store: &Store, conv: &str, list: &[Criterion]) { + let key = format!("crit:{conv}"); + if list.is_empty() { + let _ = store.del(BK_CHAT, &key).await; + return; + } + let _ = store + .put_seal( + BK_CHAT, + &key, + crate::gojson::marshal(&list.to_vec()).into_bytes(), + ) + .await; +} + +/// `toolCriteria` : `allow_pass`, la passe de vérification seule. +pub async fn exec( + store: &Store, + conv: &str, + args: &Map, + allow_pass: bool, +) -> CritOut { + let s = |k: &str| args.get(k).and_then(Value::as_str).unwrap_or(""); + let action = s("action"); + let mut list = crit_list(store, conv).await; + match action { + "list" | "" => { + if list.is_empty() { + return CritOut::text("[aucun critère]"); + } + CritOut::text(crit_render(&list)) + } + "add" => { + let raw: &[Value] = args + .get("texts") + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice); + let mut next = 1; + for c in &list { + if c.id >= next { + next = c.id + 1; + } + } + let mut added = 0; + for r in raw { + let Some(t) = r.as_str().map(go_trim).filter(|t| !t.is_empty()) else { + continue; + }; + if list.len() >= CRIT_MAX_COUNT { + break; + } + list.push(Criterion { + id: next, + text: t.to_string(), + status: "pending".into(), + note: String::new(), + }); + next += 1; + added += 1; + } + if added == 0 { + return CritOut::text("[erreur] aucun critère fourni (texts)"); + } + crit_save(store, conv, &list).await; + CritOut { + result: format!("[ok] {added} critère(s) ajouté(s)\n{}", crit_render(&list)), + notify: Some(list), + } + } + "set" => { + // id numérique ou chaîne (« "2" », « "#2" »). + let id = match args.get("id") { + Some(Value::Number(n)) => n.as_f64().map_or(0, |v| v as i64), + Some(Value::String(v)) => { + let t = go_trim(v); + go_atoi(t.strip_prefix('#').unwrap_or(t)) + } + _ => 0, + }; + let status = s("status"); + let note = s("note"); + if status == "passed" && !allow_pass { + return CritOut::text( + "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra.", + ); + } + let Some(i) = list.iter().position(|c| c.id == id) else { + return CritOut::text(format!("[erreur] critère #{id} inconnu")); + }; + if !status.is_empty() { + list[i].status = status.to_string(); + } + if !note.is_empty() { + list[i].note = go_trim(note).to_string(); + } + crit_save(store, conv, &list).await; + let result = format!("[ok] critère #{id} → {}", list[i].status); + CritOut { + result, + notify: Some(list), + } + } + "clear" => { + crit_save(store, conv, &[]).await; + CritOut { + result: "[ok] critères effacés".into(), + notify: Some(Vec::new()), + } + } + a => CritOut::text(format!("[erreur] action inconnue : {a}")), + } +} + +/// Le delta `critNotify` : la liste (vide : `[]`) et l'heure (ms). +pub fn notify_delta(list: &[Criterion], ts_ms: i64) -> Map { + let mut d = Map::new(); + d.insert("criteria".into(), json!(list)); + d.insert("ts".into(), json!(ts_ms)); + d +} + +/// Maintenant, en ms Unix. +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 3a2cda6..e72974f 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1012,6 +1012,10 @@ impl ChatService { kwargs: reasoning.kwargs.clone().unwrap_or_default(), }, queued: Box::new(move || me.drain_queued(epoch)), + deltas: { + let me = self.clone(); + Box::new(move |d| me.push_delta(d)) + }, perf_kind: super::perf::KIND_MAIN, side_slot: Default::default(), projsnap: snap_turn, diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 4cd1fb0..45a8c17 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -4,7 +4,8 @@ //! classées et formulées comme `friendlyLLMError` ; //! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé //! tenu par le Go), outils MCP par le pool de la façade quand elle le tient -//! (lot 36), outils task_* quand elle tient les tâches (lot 37) ; les autres +//! (lot 36), outils task_* quand elle tient les tâches (lot 37), critères +//! du mode Code (lot 38) ; les autres //! par le Go (`/internal/v1/tool/exec`), avec //! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur, //! image relayée) ; @@ -391,6 +392,9 @@ pub struct GoTurnHost { pub compact: TurnCompact, /// Messages mis en file par l'utilisateur pendant le tour. pub queued: Box Vec + Send + Sync>, + /// Un événement pour l'écran, hors du flux du tour (la liste des + /// critères après l'outil `criteria`). + pub deltas: Box, /// Nature des complétions pour la télémétrie : `main` (tour de la /// discussion), `verify` (passe de vérification du mode Code). pub perf_kind: &'static str, @@ -649,6 +653,24 @@ impl TurnHost for GoTurnHost { // Outils MCP, quand la façade tient le pool : ce que ferait le Go // (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux // allers-retours. Le Go n'en tire rien d'autre que le texte. + // Critères d'acceptation du mode Code (lot 38) : ceux de la + // discussion du tour ; seule la vérification marque `passed`. + if name == "criteria" { + let verifier = self.caps.get("Role").and_then(Value::as_str) == Some("verifier"); + let out = + super::crittool::exec(&self.shared.store, &self.compact.main_conv, args, verifier) + .await; + if let Some(list) = &out.notify { + (self.deltas)(super::crittool::notify_delta( + list, + super::crittool::now_ms(), + )); + } + return ToolOutcome { + result: out.result, + ..Default::default() + }; + } // Outils task_*, quand la façade tient les tâches (lot 37). if crate::tasks::tools::is_tool(name) && let Some(tasks) = &self.shared.tasks diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 2a8a384..e368a7f 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -23,6 +23,7 @@ pub mod compact; pub mod conv; +pub mod crittool; pub mod engine; pub mod generate; pub mod history; diff --git a/rust/loki-rs/src/chat/verify.rs b/rust/loki-rs/src/chat/verify.rs index 9b9e97e..81bed19 100644 --- a/rust/loki-rs/src/chat/verify.rs +++ b/rust/loki-rs/src/chat/verify.rs @@ -19,7 +19,7 @@ use std::sync::Arc; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::sync::watch; @@ -43,7 +43,7 @@ pub const CODE_MAX_FIX_TURNS: usize = 2; pub const CODE_MAX_CONTINUE: usize = 2; /// Un critère d'acceptation (`Criterion`). -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct Criterion { #[serde(default)] pub id: i64, @@ -52,7 +52,7 @@ pub struct Criterion { /// pending | completed (le builder dit « fait ») | passed | failed. #[serde(default)] pub status: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] pub note: String, } @@ -476,6 +476,12 @@ impl ChatService { // Les messages en file attendent la fin du tour (runChatTools sans // injection, comme le Go). queued: Box::new(Vec::new), + deltas: { + let conv = conv.clone(); + Box::new(move |d| { + conv.append_delta(conv.epoch(), d); + }) + }, // Passe de vérification : comptée à part, comme perfVerify. perf_kind: if isolated { super::perf::KIND_VERIFY diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 9dbcf60..4a9ac11 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -665,22 +665,6 @@ async fn handle(st: Arc, req: Request) -> Response "{name} {}", v["caps"]["Role"].as_str().unwrap_or_default() )); - if name == "criteria" && v["args"]["action"] == "set" { - let mut chat = st.store.lock().unwrap(); - for (k, val) in chat.entry("chat".into()).or_default().iter_mut() { - if !k.starts_with("crit:") { - continue; - } - let mut list: Vec = - serde_json::from_slice(val).unwrap_or_default(); - for c in &mut list { - if c["id"] == v["args"]["id"] { - c["status"] = v["args"]["status"].clone(); - } - } - *val = serde_json::to_vec(&list).unwrap(); - } - } let by_name = st.tool_results.lock().unwrap().get(name).cloned(); let result = by_name .or_else(|| st.tool_result.lock().unwrap().clone()) diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index c5a008f..758cf70 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -696,6 +696,7 @@ async fn tour_complet_par_le_go() { kwargs: Default::default(), }, queued: Box::new(Vec::new), + deltas: Box::new(|_| {}), perf_kind: "main", side_slot: Default::default(), projsnap: None, @@ -1089,6 +1090,7 @@ async fn hote_et_sonde_de_gabarit() { kwargs: Default::default(), }, queued: Box::new(Vec::new), + deltas: Box::new(|_| {}), perf_kind: "main", side_slot: Default::default(), projsnap: None, @@ -1259,7 +1261,20 @@ async fn contrat_verifie_par_la_facade() { Verify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool." ); assert_eq!(v["tools"][0]["function"]["name"], "criteria"); - assert_eq!(*go.tool_log.lock().unwrap(), ["criteria verifier"]); + // L'outil criteria s'exécute dans la façade (lot 38) : rien par le Go ; + // le critère rangé (scellé) passe à « passed », et l'écran reçoit la + // liste. + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + let stored = go.get("chat", &format!("crit:{id}")).unwrap(); + assert_eq!( + stored, + r#"SCELLE:[{"id":1,"text":"les tests passent","status":"passed"}]"# + ); + let crit = deltas(&svc, "criteria"); + assert_eq!( + crit, + [serde_json::json!([{"id":1,"text":"les tests passent","status":"passed"}])] + ); assert_eq!( deltas(&svc, "role"), [serde_json::json!("verifier"), serde_json::json!("")] diff --git a/rust/loki-rs/tests/parity_tools/criteria.json b/rust/loki-rs/tests/parity_tools/criteria.json new file mode 100644 index 0000000..24656a1 --- /dev/null +++ b/rust/loki-rs/tests/parity_tools/criteria.json @@ -0,0 +1,648 @@ +[ + { + "name": "liste vide", + "args": { + "action": "list" + }, + "out": "[aucun critère]", + "stored": null + }, + { + "name": "action absente : liste", + "args": {}, + "out": "[aucun critère]", + "stored": null + }, + { + "name": "action d'un autre type", + "args": { + "action": 3 + }, + "out": "[aucun critère]", + "stored": null + }, + { + "name": "ajout sans textes", + "args": { + "action": "add" + }, + "out": "[erreur] aucun critère fourni (texts)", + "stored": null + }, + { + "name": "ajout de textes vides ou d'un autre type", + "args": { + "action": "add", + "texts": [ + " ", + 4, + null + ] + }, + "out": "[erreur] aucun critère fourni (texts)", + "stored": null + }, + { + "name": "ajout", + "args": { + "action": "add", + "texts": [ + " les tests passent ", + "la doc \u003cà jour\u003e \u0026 relue", + "" + ] + }, + "out": "[ok] 2 critère(s) ajouté(s)\n[ ] #1 les tests passent\n[ ] #2 la doc \u003cà jour\u003e \u0026 relue", + "stored": [ + { + "id": 1, + "status": "pending", + "text": "les tests passent" + }, + { + "id": 2, + "status": "pending", + "text": "la doc \u003cà jour\u003e \u0026 relue" + } + ] + }, + { + "name": "ajout à la suite", + "args": { + "action": "add", + "texts": [ + "pas de régression" + ] + }, + "out": "[ok] 1 critère(s) ajouté(s)\n[ ] #1 les tests passent\n[ ] #2 la doc \u003cà jour\u003e \u0026 relue\n[ ] #3 pas de régression", + "stored": [ + { + "id": 1, + "status": "pending", + "text": "les tests passent" + }, + { + "id": 2, + "status": "pending", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "pending", + "text": "pas de régression" + } + ] + }, + { + "name": "le builder dit fait", + "args": { + "action": "set", + "id": 1, + "status": "completed", + "note": " vérifié à la main " + }, + "out": "[ok] critère #1 → completed", + "stored": [ + { + "id": 1, + "note": "vérifié à la main", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "status": "pending", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "pending", + "text": "pas de régression" + } + ] + }, + { + "name": "le builder ne valide pas", + "args": { + "action": "set", + "id": 1, + "status": "passed" + }, + "out": "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra.", + "stored": [ + { + "id": 1, + "note": "vérifié à la main", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "status": "pending", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "pending", + "text": "pas de régression" + } + ] + }, + { + "name": "id en chaîne avec #", + "verifier": true, + "args": { + "action": "set", + "id": " #2 ", + "status": "failed", + "note": "lien mort" + }, + "out": "[ok] critère #2 → failed", + "stored": [ + { + "id": 1, + "note": "vérifié à la main", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "failed", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "pending", + "text": "pas de régression" + } + ] + }, + { + "name": "id en chaîne illisible", + "verifier": true, + "args": { + "action": "set", + "id": "deux", + "status": "passed" + }, + "out": "[erreur] critère #0 inconnu", + "stored": [ + { + "id": 1, + "note": "vérifié à la main", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "failed", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "pending", + "text": "pas de régression" + } + ] + }, + { + "name": "id décimal tronqué", + "verifier": true, + "args": { + "action": "set", + "id": 3.9, + "status": "passed" + }, + "out": "[ok] critère #3 → passed", + "stored": [ + { + "id": 1, + "note": "vérifié à la main", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "failed", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "note seule", + "args": { + "action": "set", + "id": "+1", + "note": "revu" + }, + "out": "[ok] critère #1 → completed", + "stored": [ + { + "id": 1, + "note": "revu", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "failed", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "statut libre", + "args": { + "action": "set", + "id": 2, + "status": "bloqué" + }, + "out": "[ok] critère #2 → bloqué", + "stored": [ + { + "id": 1, + "note": "revu", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "bloqué", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "id inconnu", + "verifier": true, + "args": { + "action": "set", + "id": 9, + "status": "passed" + }, + "out": "[erreur] critère #9 inconnu", + "stored": [ + { + "id": 1, + "note": "revu", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "bloqué", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "liste", + "args": { + "action": "list" + }, + "out": "[~] #1 les tests passent — revu\n[ ] #2 la doc \u003cà jour\u003e \u0026 relue — lien mort\n[✓] #3 pas de régression", + "stored": [ + { + "id": 1, + "note": "revu", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "bloqué", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "action inconnue", + "args": { + "action": "reset" + }, + "out": "[erreur] action inconnue : reset", + "stored": [ + { + "id": 1, + "note": "revu", + "status": "completed", + "text": "les tests passent" + }, + { + "id": 2, + "note": "lien mort", + "status": "bloqué", + "text": "la doc \u003cà jour\u003e \u0026 relue" + }, + { + "id": 3, + "status": "passed", + "text": "pas de régression" + } + ] + }, + { + "name": "effacer", + "args": { + "action": "clear" + }, + "out": "[ok] critères effacés", + "stored": null + }, + { + "name": "effacer à vide", + "args": { + "action": "clear" + }, + "out": "[ok] critères effacés", + "stored": null + }, + { + "name": "liste pleine", + "put": "[{\"id\":7,\"text\":\"a\",\"status\":\"passed\"},{\"id\":3,\"text\":\"b\",\"status\":\"pending\"}]", + "args": { + "action": "add", + "texts": [ + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w" + ] + }, + "out": "[ok] 18 critère(s) ajouté(s)\n[✓] #7 a\n[ ] #3 b\n[ ] #8 c\n[ ] #9 d\n[ ] #10 e\n[ ] #11 f\n[ ] #12 g\n[ ] #13 h\n[ ] #14 i\n[ ] #15 j\n[ ] #16 k\n[ ] #17 l\n[ ] #18 m\n[ ] #19 n\n[ ] #20 o\n[ ] #21 p\n[ ] #22 q\n[ ] #23 r\n[ ] #24 s\n[ ] #25 t", + "stored": [ + { + "id": 7, + "status": "passed", + "text": "a" + }, + { + "id": 3, + "status": "pending", + "text": "b" + }, + { + "id": 8, + "status": "pending", + "text": "c" + }, + { + "id": 9, + "status": "pending", + "text": "d" + }, + { + "id": 10, + "status": "pending", + "text": "e" + }, + { + "id": 11, + "status": "pending", + "text": "f" + }, + { + "id": 12, + "status": "pending", + "text": "g" + }, + { + "id": 13, + "status": "pending", + "text": "h" + }, + { + "id": 14, + "status": "pending", + "text": "i" + }, + { + "id": 15, + "status": "pending", + "text": "j" + }, + { + "id": 16, + "status": "pending", + "text": "k" + }, + { + "id": 17, + "status": "pending", + "text": "l" + }, + { + "id": 18, + "status": "pending", + "text": "m" + }, + { + "id": 19, + "status": "pending", + "text": "n" + }, + { + "id": 20, + "status": "pending", + "text": "o" + }, + { + "id": 21, + "status": "pending", + "text": "p" + }, + { + "id": 22, + "status": "pending", + "text": "q" + }, + { + "id": 23, + "status": "pending", + "text": "r" + }, + { + "id": 24, + "status": "pending", + "text": "s" + }, + { + "id": 25, + "status": "pending", + "text": "t" + } + ] + }, + { + "name": "liste déjà pleine", + "args": { + "action": "add", + "texts": [ + "x" + ] + }, + "out": "[erreur] aucun critère fourni (texts)", + "stored": [ + { + "id": 7, + "status": "passed", + "text": "a" + }, + { + "id": 3, + "status": "pending", + "text": "b" + }, + { + "id": 8, + "status": "pending", + "text": "c" + }, + { + "id": 9, + "status": "pending", + "text": "d" + }, + { + "id": 10, + "status": "pending", + "text": "e" + }, + { + "id": 11, + "status": "pending", + "text": "f" + }, + { + "id": 12, + "status": "pending", + "text": "g" + }, + { + "id": 13, + "status": "pending", + "text": "h" + }, + { + "id": 14, + "status": "pending", + "text": "i" + }, + { + "id": 15, + "status": "pending", + "text": "j" + }, + { + "id": 16, + "status": "pending", + "text": "k" + }, + { + "id": 17, + "status": "pending", + "text": "l" + }, + { + "id": 18, + "status": "pending", + "text": "m" + }, + { + "id": 19, + "status": "pending", + "text": "n" + }, + { + "id": 20, + "status": "pending", + "text": "o" + }, + { + "id": 21, + "status": "pending", + "text": "p" + }, + { + "id": 22, + "status": "pending", + "text": "q" + }, + { + "id": 23, + "status": "pending", + "text": "r" + }, + { + "id": 24, + "status": "pending", + "text": "s" + }, + { + "id": 25, + "status": "pending", + "text": "t" + } + ] + }, + { + "name": "liste rangée illisible", + "put": "{\"pas\":\"une liste\"}", + "args": { + "action": "list" + }, + "out": "[aucun critère]", + "stored": { + "pas": "une liste" + } + } +] diff --git a/rust/loki-rs/tests/tools.rs b/rust/loki-rs/tests/tools.rs index 0664cdc..cae9487 100644 --- a/rust/loki-rs/tests/tools.rs +++ b/rust/loki-rs/tests/tools.rs @@ -67,3 +67,53 @@ async fn le_reste_va_au_go() { let args = a(json!({"pattern":"*"})); assert!(exec_local(&st, false, "glob", &args, "*").await.is_some()); } + +/// Lot 38 : l'outil criteria du mode Code, rejoué contre le journal du Go +/// (`internal/loki/parity_criteria_test.go` → `parity_tools/criteria.json`) : +/// résultat de chaque appel, critères rangés après coup (scellés), et la +/// liste poussée à l'écran quand elle change. +#[tokio::test] +async fn criteria_comme_le_go() { + use loki_rs::chat::crittool::{CRIT_MAX_COUNT, exec}; + let fx: Value = + serde_json::from_str(include_str!("parity_tools/criteria.json")).expect("journal"); + let t = tempfile::tempdir().unwrap(); + let (_f, go, _stop) = common::fakego::start(t.path().into()).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = loki_rs::store::Store::new(backend, common::fakego::JETON.into()); + assert_eq!(CRIT_MAX_COUNT, 20); + let conv = "c1"; + for st in fx.as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + if let Some(put) = st["put"].as_str() { + store + .put_seal("chat", &format!("crit:{conv}"), put.as_bytes().to_vec()) + .await + .unwrap(); + } + let args = st["args"].as_object().cloned().unwrap_or_default(); + let verifier = st["verifier"].as_bool() == Some(true); + let before = store.get_open("chat", &format!("crit:{conv}")).await.unwrap(); + let out = exec(&store, conv, &args, verifier).await; + assert_eq!(out.result, st["out"].as_str().unwrap(), "{name}"); + let raw = store.get_open("chat", &format!("crit:{conv}")).await.unwrap(); + let stored = raw + .as_deref() + .filter(|b| !b.is_empty()) + .map(|b| serde_json::from_slice::(b).unwrap()) + .unwrap_or(Value::Null); + assert_eq!(stored, st["stored"], "{name}"); + // Scellés comme le contenu des discussions. + if let Some(sealed) = go.get("chat", &format!("crit:{conv}")) { + assert!(sealed.starts_with("SCELLE:"), "{name} : {sealed}"); + } + // L'écran reçoit la liste quand elle change (vide : effacée). + match &out.notify { + Some(list) => { + let want = if stored.is_null() { json!([]) } else { stored.clone() }; + assert_eq!(json!(list), want, "{name}"); + } + None => assert_eq!(raw, before, "{name} : rien ne change sans notification"), + } + } +}