Files
Loki/internal/loki/tasks_test.go
T
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00

323 lines
11 KiB
Go

package loki
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// Aller-retour complet en base : ce qui est enregistré se relit à l'identique,
// la liste est triée par nom (affichage stable) et la suppression efface bien.
func TestTasksCRUD(t *testing.T) {
testHome(t)
if got := listTasks(); len(got) != 0 {
t.Fatalf("base neuve : %d tâches, attendu 0", len(got))
}
a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true}
b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"}
if err := saveTask(a); err != nil {
t.Fatal(err)
}
if err := saveTask(b); err != nil {
t.Fatal(err)
}
list := listTasks()
if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" {
t.Fatalf("liste non triée par nom : %+v", list)
}
got, ok := getTask(a.ID)
if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled {
t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok)
}
if err := deleteTask(a.ID); err != nil {
t.Fatal(err)
}
if _, ok := getTask(a.ID); ok {
t.Error("tâche supprimée toujours présente")
}
if len(listTasks()) != 1 {
t.Error("la suppression n'a pas retiré la tâche de la liste")
}
}
// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans
// dépendre d'une écriture préalable.
func TestTasksPauseDefautActif(t *testing.T) {
testHome(t)
if tasksPaused() {
t.Error("base neuve : les tâches devraient être actives")
}
if err := setTasksPaused(true); err != nil {
t.Fatal(err)
}
if !tasksPaused() {
t.Error("pause non enregistrée")
}
_ = setTasksPaused(false)
if tasksPaused() {
t.Error("reprise non enregistrée")
}
}
// Le dossier de travail d'une tâche lui appartient : jamais celui d'une
// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une
// tâche qui tient un fichier de suivi doit le retrouver.
func TestTaskWorkspaceIsole(t *testing.T) {
testHome(t)
dir := taskWorkspaceDir("abc123")
if dir == agentWorkspace() || dir == convWorkspace() {
t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir)
}
if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" {
t.Errorf("emplacement inattendu : %s", dir)
}
if again := taskWorkspaceDir("abc123"); again != dir {
t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again)
}
// La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur
// (panneau Fichiers, dépôts) reste sur sa discussion.
setTaskWorkspace(dir)
defer setTaskWorkspace("")
if agentCwd() != dir {
t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir)
}
if convWorkspace() == dir {
t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route")
}
}
// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER
// (no_mem/no_web), et le mode code reste hors sujet sans personne en face.
func TestTaskCaps(t *testing.T) {
testHome(t)
if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff {
t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c)
}
if err := setAgentEnabled(true); err != nil {
t.Fatal(err)
}
c := taskCaps(Task{})
if !c.Agent {
t.Fatal("mode agent actif : la tâche doit avoir les outils")
}
if c.Code {
t.Error("le mode code n'a pas de sens pour une tâche autonome")
}
// Par défaut, la tâche suit le mode mémoire de la machine.
if c.Mem != memMode() {
t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode())
}
// Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les
// outils à la demande, sans l'injection proactive.
if err := setMemMode(MemOff); err != nil {
t.Fatal(err)
}
if got := taskCaps(Task{}).Mem; got != MemOnDemand {
t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand)
}
if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet {
t.Errorf("refus explicite non respecté : %+v", c)
}
// Web : borné à ce que la machine offre réellement — une tâche ne peut pas
// l'inventer (aucun serveur Crawl4AI joignable ici).
if c := taskCaps(Task{}); c.Internet {
t.Error("accès web accordé alors que la machine ne l'offre pas")
}
}
// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la
// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge).
func TestRecordTaskEnd(t *testing.T) {
testHome(t)
base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true}
if err := saveTask(base); err != nil {
t.Fatal(err)
}
start := time.Now().Add(-2 * time.Second)
recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil)
got, _ := getTask("t1")
if !got.LastOK || got.LastError != "" {
t.Errorf("succès mal enregistré : %+v", got)
}
if len(got.LastReport) != reportMax+len("…") {
t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport))
}
if got.LastRun == 0 || got.NextRun <= got.LastRun {
t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun)
}
if got.LastDurMs < 1000 {
t.Errorf("durée non mesurée : %d ms", got.LastDurMs)
}
recordTaskEnd("t1", start, "", errors.New("boum"))
got, _ = getTask("t1")
if got.LastOK || got.LastError != "boum" {
t.Errorf("échec mal enregistré : %+v", got)
}
recordTaskEnd("t1", start, "", context.Canceled)
got, _ = getTask("t1")
if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") {
t.Errorf("interruption prise pour un échec : %+v", got)
}
// Tâche supprimée entre-temps : on n'en ressuscite pas une.
recordTaskEnd("disparue", start, "r", nil)
if _, ok := getTask("disparue"); ok {
t.Error("recordTaskEnd a recréé une tâche supprimée")
}
}
// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le
// NextRun manquant sans lancer la tâche pour autant.
func TestTickTasks(t *testing.T) {
testHome(t)
now := time.Now()
due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true,
NextRun: now.Add(-time.Minute).UnixMilli()}
if err := saveTask(due); err != nil {
t.Fatal(err)
}
_ = setTasksPaused(true)
tickTasks(now)
if got, _ := getTask("due"); got.LastRun != 0 {
t.Error("une tâche s'est exécutée alors que tout est suspendu")
}
_ = setTasksPaused(false)
// NextRun absent : le tic le pose (relatif à maintenant) sans exécuter.
neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true}
if err := saveTask(neuve); err != nil {
t.Fatal(err)
}
primeNextRuns()
got, _ := getTask("neuve")
if got.NextRun == 0 || got.LastRun != 0 {
t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun)
}
if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute {
t.Errorf("prochain passage à %v, attendu ~2 h", d)
}
// Tâche désactivée : jamais amorcée, jamais lancée.
off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"}
if err := saveTask(off); err != nil {
t.Fatal(err)
}
primeNextRuns()
if got, _ := getTask("off"); got.NextRun != 0 {
t.Error("une tâche désactivée a été planifiée")
}
}
// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle
// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle).
func TestRunTaskPresetIntrouvable(t *testing.T) {
testHome(t)
tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"}
if err := saveTask(tk); err != nil {
t.Fatal(err)
}
runTask(tk)
got, _ := getTask("p1")
if got.LastOK || !strings.Contains(got.LastError, "preset") {
t.Errorf("échec de bascule mal signalé : %+v", got)
}
}
// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son
// compte-rendu précédent — borné, pour ne pas manger le contexte.
func TestTaskContextNote(t *testing.T) {
n := taskContextNote("Veille mails", "")
if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") {
t.Errorf("note incomplète : %q", n)
}
if strings.Contains(n, "dernière exécution") {
t.Error("premier passage : il n'y a pas de compte-rendu à rappeler")
}
long := taskContextNote("t", strings.Repeat("y", reportMax+100))
if !strings.Contains(long, "dernière exécution") {
t.Error("le compte-rendu précédent doit être réinjecté")
}
if len(long) > reportMax+600 {
t.Errorf("note non bornée : %d caractères", len(long))
}
}
// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence
// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire.
func TestHandleTaskSaveValide(t *testing.T) {
testHome(t)
post := func(body string) (int, map[string]any) {
rr := httptest.NewRecorder()
handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr.Code, out
}
if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 {
t.Errorf("nom vide accepté : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 {
t.Errorf("consigne vide acceptée : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 {
t.Errorf("fréquence illisible acceptée : %d %v", code, out)
}
if len(listTasks()) != 0 {
t.Fatal("une tâche refusée a quand même été enregistrée")
}
code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`)
if code != 200 || out["ok"] != true {
t.Fatalf("création refusée : %d %v", code, out)
}
id, _ := out["id"].(string)
got, ok := getTask(id)
if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 {
t.Fatalf("tâche mal créée : %+v", got)
}
// Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce
// qu'on a corrigé une faute dans la consigne).
got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true
if err := saveTask(got); err != nil {
t.Fatal(err)
}
if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 {
t.Fatalf("édition refusée : %d %v", code, out)
}
after, _ := getTask(id)
if after.Name != "n2" || after.Prompt != "p2" {
t.Errorf("édition non appliquée : %+v", after)
}
if after.LastRun != 42 || after.LastReport != "vu hier" {
t.Errorf("historique perdu à l'édition : %+v", after)
}
}
// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le
// modèle : « génération en cours » sur un fil vide et immobile n'explique rien.
func TestBusyReasonNommeLaTache(t *testing.T) {
testHome(t)
if err := conv.busyReason(); err != ErrBusy {
t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy)
}
conv.mu.Lock()
conv.runningTaskName = "Veille mails"
conv.mu.Unlock()
defer func() {
conv.mu.Lock()
conv.runningTaskName = ""
conv.mu.Unlock()
}()
if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") {
t.Errorf("la tâche n'est pas nommée : %v", err)
}
}