package agent
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"harness/internal/llm"
)
func TestSaveAndResumeSession(t *testing.T) {
dir := t.TempDir()
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("tu_1", "boom", `{}`)),
msg("end_turn", textBlock("first answer")),
)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
if _, err := a.Run(context.Background(), "the original task"); err != nil {
t.Fatalf("Run: %v", err)
}
if err := a.Save(dir, "sess-1", "/repo", "claude-opus-5"); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := ResolveSession(dir, "sess-1")
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if loaded.Model != "claude-opus-5" || loaded.Root != "/repo" || loaded.Turns != 2 {
t.Errorf("session = %+v", loaded)
}
if len(loaded.Messages) != len(a.State().History()) {
t.Errorf("%d messages saved, want %d", len(loaded.Messages), len(a.State().History()))
}
// A resumed agent continues the same conversation.
f2 := newFakeAPI(t, msg("end_turn", textBlock("second answer")))
b := newTestAgent(t, f2, t.TempDir(), &bytes.Buffer{})
b.Restore(loaded)
if _, err := b.Run(context.Background(), "and now this"); err != nil {
t.Fatalf("Run after restore: %v", err)
}
sent := f2.rawRequest(0)
if !strings.Contains(sent, "the original task") {
t.Errorf("resumed request lost the earlier conversation:\n%s", sent)
}
if !strings.Contains(sent, "and now this") {
t.Error("resumed request is missing the new prompt")
}
if _, turns := b.State().Usage(); turns != 3 {
t.Errorf("turns = %d, want the restored 2 plus 1", turns)
}
}
func TestSavePreservesCreatedTime(t *testing.T) {
dir := t.TempDir()
f := newFakeAPI(t, msg("end_turn", textBlock("x")), msg("end_turn", textBlock("y")))
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.Run(context.Background(), "one")
a.Save(dir, "s", "/r", "m")
first, _ := LoadSession(filepath.Join(dir, "s.json"))
time.Sleep(5 * time.Millisecond)
a.Run(context.Background(), "two")
a.Save(dir, "s", "/r", "m")
second, _ := LoadSession(filepath.Join(dir, "s.json"))
if !second.Created.Equal(first.Created) {
t.Errorf("created moved from %v to %v across saves", first.Created, second.Created)
}
if !second.Updated.After(first.Updated) {
t.Error("updated should move forward")
}
}
func TestResolveLatestSession(t *testing.T) {
dir := t.TempDir()
for _, id := range []string{"older", "newer"} {
s := Session{ID: id, Messages: []llm.Message{{Role: llm.RoleUser}}}
writeSession(t, dir, s)
time.Sleep(10 * time.Millisecond)
}
got, err := ResolveSession(dir, "latest")
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if got.ID != "newer" {
t.Errorf("resolved %q, want the most recently updated", got.ID)
}
if _, err := ResolveSession(t.TempDir(), "latest"); err == nil {
t.Error("an empty session directory should be an error, not an empty session")
}
if _, err := ResolveSession(dir, "nonexistent"); err == nil {
t.Error("an unknown id should be an error")
}
}
func writeSession(t *testing.T, dir string, s Session) {
t.Helper()
a := &Agent{state: NewState()}
a.state.Replace(s.Messages)
if err := a.Save(dir, s.ID, "/r", "m"); err != nil {
t.Fatal(err)
}
}
func TestLoadSessionRejectsGarbage(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o644)
if _, err := ResolveSession(dir, "broken"); err == nil {
t.Error("want an error for a corrupt session file")
}
}