post inicial
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user