package agent import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "sysagent/internal/ctxmgr" "sysagent/internal/llm" "sysagent/internal/memory" ) func llmUser(s string) llm.Message { return llm.Message{Role: llm.RoleUser, Content: s} } func isCompression(req map[string]any) bool { msgs := messages(req) return strings.Contains(msgs[0]["content"].(string), "You compress the historical execution context") } func summaryText(marker string) string { var b strings.Builder for _, s := range ctxmgr.RequiredSections { b.WriteString("## " + s + "\n- none\n") } return b.String() + "- " + marker + "\n" } func loadState(t *testing.T, dir string) *memory.State { t.Helper() st, warn, err := memory.LoadState(filepath.Join(dir, "contexto.mem")) if err != nil || warn != "" { t.Fatalf("LoadState: %v %q", err, warn) } return st } func TestStatePersistedAndDoneTasksSkipped(t *testing.T) { m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) { msgs := messages(req) if msgs[len(msgs)-1]["role"] == "tool" { return 200, text("Hecho y verificado.\nTASK_STATUS: DONE") } return 200, toolCall("c1", "exec", map[string]any{"command": "echo", "args": []string{"ok"}}) }} srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD, "01-a.md": "tarea uno"}) a, _ := bootstrap(t, dir, false, "") if err := a.RunTasks(context.Background()); err != nil { t.Fatal(err) } a.Close() st := loadState(t, dir) if st.Tasks["01-a.md"].Status != "DONE" || st.Tasks["01-a.md"].Hash == "" { t.Fatalf("task record = %+v", st.Tasks["01-a.md"]) } kinds := map[string]bool{} for _, j := range st.Journal { kinds[j.Kind] = true } for _, k := range []string{"session", "status", "tool", "answer"} { if !kinds[k] { t.Errorf("journal missing kind %q: %+v", k, st.Journal) } } if fi, _ := os.Stat(filepath.Join(dir, "contexto.mem")); fi.Mode().Perm() != 0o600 { t.Errorf("contexto.mem perm = %o", fi.Mode().Perm()) } raw, _ := os.ReadFile(filepath.Join(dir, "contexto.mem")) if strings.Contains(string(raw), "sk-test-secret-key-000") { t.Error("API key persisted in contexto.mem") } // Segunda ejecución: la tarea no cambió → no se llama al modelo. before := len(m.requests) b, out := bootstrap(t, dir, false, "") if err := b.RunTasks(context.Background()); err != nil { t.Fatal(err) } if len(m.requests) != before { t.Errorf("DONE task was executed again (%d new requests)", len(m.requests)-before) } if !strings.Contains(out.String(), "DONE, skipped") { t.Errorf("output:\n%s", out.String()) } b.Close() // Si el archivo cambia, se ejecuta otra vez. os.WriteFile(filepath.Join(dir, "01-a.md"), []byte("tarea uno, versión 2"), 0o644) c, _ := bootstrap(t, dir, false, "") c.RunTasks(context.Background()) c.Close() if len(m.requests) == before { t.Error("modified task must run again") } // --rerun fuerza la ejecución. before = len(m.requests) var stdout, stderr bytes.Buffer d, err := Bootstrap(context.Background(), Options{Root: dir, Rerun: true, Stdout: &stdout, Stderr: &stderr, Version: "test"}) if err != nil { t.Fatal(err) } d.RunTasks(context.Background()) d.Close() if len(m.requests) == before { t.Error("--rerun must execute DONE tasks") } } func TestRememberUpdatesPromptImmediately(t *testing.T) { m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) { if n == 1 { return 200, toolCall("r1", "remember", map[string]any{"section": "Hosts", "entry": "db01", "facts": []string{"IP: 10.10.10.15", "Puerto: 5432"}}) } return 200, text("Guardado.\nTASK_STATUS: DONE") }} srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD, "01-a.md": "descubre db01"}) a, _ := bootstrap(t, dir, false, "") if err := a.RunTasks(context.Background()); err != nil { t.Fatal(err) } data, _ := os.ReadFile(filepath.Join(dir, "memoria.md")) if !strings.Contains(string(data), "### db01") || !strings.Contains(string(data), "- IP: 10.10.10.15") { t.Fatalf("memoria.md:\n%s", data) } system := messages(m.requests[1])[0]["content"].(string) if !strings.Contains(system, "IP: 10.10.10.15") { t.Error("system prompt was not refreshed with the new memory") } } func TestAutomaticCompaction(t *testing.T) { big := strings.Repeat("línea de log con datos 192.168.20.15\n", 120) // ~4.4 KB por salida var compressions int m := &scriptedModel{} m.reply = func(n int, req map[string]any) (int, any) { if isCompression(req) { compressions++ in := messages(req)[1]["content"].(string) if !strings.Contains(in, "JOURNAL:") || !strings.Contains(in, "TRANSCRIPT") { t.Errorf("compression input incomplete") } return 200, text(summaryText("RESUMEN-MARCADOR db 192.168.20.15:5432")) } msgs := messages(req) tools := 0 for _, msg := range msgs { if msg["role"] == "tool" { tools++ } } if tools >= 8 || compressions > 0 && msgs[len(msgs)-1]["role"] == "tool" && tools >= 2 { return 200, text("Terminado.\nTASK_STATUS: DONE") } return 200, toolCall("c"+string(rune('a'+n)), "shell", map[string]any{"script": "printf '" + big + "' # " + string(rune('a'+n))}) } srv := m.server(t) defer srv.Close() dir := t.TempDir() os.WriteFile(filepath.Join(dir, "init.conf"), []byte("API_URL="+srv.URL+"/v1\nMODEL=test-model\nCONTEXT_SIZE=16000\n"), 0o600) os.WriteFile(filepath.Join(dir, "agent.md"), []byte(agentMD), 0o644) os.WriteFile(filepath.Join(dir, "01-a.md"), []byte("analiza los logs"), 0o644) a, out := bootstrap(t, dir, false, "") if err := a.RunTasks(context.Background()); err != nil { t.Fatal(err) } if compressions == 0 { t.Fatalf("no compaction happened; output:\n%s", out.String()) } if !strings.Contains(out.String(), "compacting context") { t.Error("compaction not shown on console") } // Tras compactar, el resumen está en el prompt de sistema y el historial // reciente empieza por un mensaje de usuario con la tarea en curso. var after map[string]any for _, r := range m.requests { if !isCompression(r) && strings.Contains(messages(r)[0]["content"].(string), "RESUMEN-MARCADOR") { after = r break } } if after == nil { t.Fatal("summary never reached the system prompt") } msgs := messages(after) if msgs[1]["role"] != "user" || !strings.Contains(msgs[1]["content"].(string), "analiza los logs") { t.Errorf("first kept message must restate the task: %v", msgs[1]) } for i := 1; i < len(msgs); i++ { if msgs[i]["role"] == "tool" && msgs[i-1]["role"] == "user" { t.Errorf("tool result separated from its call at %d", i) } } a.Close() st := loadState(t, dir) if !strings.Contains(st.Summary, "RESUMEN-MARCADOR") || st.Compactions == 0 { t.Errorf("summary not persisted: %d compactions", st.Compactions) } if len(st.Unsummarized()) == len(st.Journal) { t.Error("journal entries must be marked as summarized") } } func TestOverflowErrorTriggersCompaction(t *testing.T) { overflowed := false m := &scriptedModel{} m.reply = func(n int, req map[string]any) (int, any) { if isCompression(req) { return 200, text(summaryText("TRAS-DESBORDE")) } msgs := messages(req) if len(msgs) > 4 && !overflowed { overflowed = true return 400, map[string]any{"error": map[string]any{"message": "the request exceeds the available context size"}} } if strings.Contains(msgs[0]["content"].(string), "TRAS-DESBORDE") { return 200, text("ok.\nTASK_STATUS: DONE") } return 200, toolCall("c"+string(rune('a'+n)), "exec", map[string]any{"command": "echo", "args": []string{string(rune('a' + n))}}) } srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD, "01-a.md": "tarea"}) a, _ := bootstrap(t, dir, false, "") // Tamaño UNKNOWN: no hay compactación preventiva; solo reacciona al // error de desbordamiento del servidor. a.ctx.SetContextSize(0) if err := a.RunTasks(context.Background()); err != nil { t.Fatal(err) } if !overflowed || a.taskStatus["01-a.md"] != StatusDone { t.Errorf("overflowed=%v status=%s", overflowed, a.taskStatus["01-a.md"]) } } func TestCLIResumesPreviousSession(t *testing.T) { m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) { return 200, text("respuesta " + string(rune('0'+n))) }} srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD}) a, _ := bootstrap(t, dir, true, "el servidor web es web01 en 10.0.0.8\n/quit\n") if err := a.RunCLI(context.Background(), make(chan os.Signal)); err != nil { t.Fatal(err) } a.Close() b, out := bootstrap(t, dir, true, "/context\n¿qué servidor web te dije?\n/journal 5\n/quit\n") if err := b.RunCLI(context.Background(), make(chan os.Signal)); err != nil { t.Fatal(err) } b.Close() system := messages(m.requests[len(m.requests)-1])[0]["content"].(string) if !strings.Contains(system, "ÚLTIMOS MENSAJES DE LA SESIÓN ANTERIOR") || !strings.Contains(system, "web01 en 10.0.0.8") { t.Errorf("previous session not restored in system prompt") } st := loadState(t, dir) all, _ := json.Marshal(st.Recent) if !strings.Contains(string(all), "web01 en 10.0.0.8") || !strings.Contains(string(all), "qué servidor web") { t.Error("recent messages of both sessions must be kept until summarized") } for _, want := range []string{"Previous session messages loaded", "Journal:", "[session]", "[answer]"} { if !strings.Contains(out.String(), want) { t.Errorf("output missing %q:\n%s", want, out.String()) } } } func TestCompactCommand(t *testing.T) { m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) { if isCompression(req) { return 200, text(summaryText("MANUAL")) } return 200, text("r") }} srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD}) a, out := bootstrap(t, dir, true, "/compact\nuno\ndos\n/compact\n/context summary\n/quit\n") if err := a.RunCLI(context.Background(), make(chan os.Signal)); err != nil { t.Fatal(err) } for _, want := range []string{"Nothing to compact yet.", "Context compacted.", "1 compaction(s)", "- MANUAL"} { if !strings.Contains(out.String(), want) { t.Errorf("output missing %q:\n%s", want, out.String()) } } all, _ := json.Marshal(m.requests) if !strings.Contains(string(all), "You compress the historical execution context") { t.Error("compression request not sent") } } func TestLargePreviousSessionIsCompactedFirst(t *testing.T) { compressed := false m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) { if isCompression(req) { compressed = true if !strings.Contains(messages(req)[1]["content"].(string), "MENSAJE-ANTIGUO-0") { t.Error("oldest previous-session message must reach the compressor") } return 200, text(summaryText("PREVIO")) } return 200, text("ok") }} srv := m.server(t) defer srv.Close() dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD}) st := memory.NewState() for i := 0; i < 50; i++ { st.Recent = append(st.Recent, llmUser("MENSAJE-ANTIGUO-"+string(rune('0'+i%10))+" "+strings.Repeat("x", 3000))) } if err := st.Save(filepath.Join(dir, "contexto.mem"), func(s string) string { return s }); err != nil { t.Fatal(err) } a, _ := bootstrap(t, dir, true, "hola\n/quit\n") if !a.compactPending { t.Fatal("expected pending compaction") } if err := a.RunCLI(context.Background(), make(chan os.Signal)); err != nil { t.Fatal(err) } if !compressed || a.prevSession != "" || !strings.Contains(a.state.Summary, "PREVIO") { t.Errorf("compressed=%v prevSession=%d summary=%q", compressed, len(a.prevSession), a.state.Summary) } }