package agent
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codey/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")
}
}
// A session belongs to the directory it was recorded in. The chooser is built
// on this: work done in /home/joni/test must not be reopenable in
// /home/joni/test2, where none of the paths it discusses exist.
func TestListSessionsOnlyOffersThisRoots(t *testing.T) {
dir := t.TempDir()
put := func(id, root, prompt string, age time.Duration) {
t.Helper()
s := Session{
ID: id, Root: root, Model: "claude-opus-5", Turns: 2,
Updated: time.Now().Add(-age),
Messages: []llm.Message{{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}}},
}
b, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, id+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
put("old", "/home/joni/test", "rename the widget", 2*time.Hour)
put("new", "/home/joni/test", "fix the parser", time.Minute)
put("elsewhere", "/home/joni/test2", "a different checkout", time.Second)
// A directory is not a session, and neither is a file that will not parse:
// one bad file must not hide the good ones.
os.Mkdir(filepath.Join(dir, "subdir.json"), 0o755)
os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o644)
got, err := ListSessions(dir, "/home/joni/test/")
if err != nil {
t.Fatalf("ListSessions: %v", err)
}
var ids []string
for _, s := range got {
ids = append(ids, s.ID)
}
if strings.Join(ids, ",") != "new,old" {
t.Errorf("listed %v, want this root's sessions, newest first", ids)
}
if got[0].Title != "fix the parser" {
t.Errorf("title = %q, want the first prompt", got[0].Title)
}
if got[0].Turns != 2 || got[0].Messages != 1 || got[0].Model != "claude-opus-5" {
t.Errorf("row = %+v, want the session's own numbers", got[0])
}
// The other checkout sees only its own, and a directory with nothing in it
// is an empty list rather than an error: it means no session yet.
other, err := ListSessions(dir, "/home/joni/test2")
if err != nil || len(other) != 1 || other[0].ID != "elsewhere" {
t.Errorf("ListSessions for the other root = %+v, %v", other, err)
}
if list, err := ListSessions(filepath.Join(t.TempDir(), "never-saved"), "/r"); err != nil || len(list) != 0 {
t.Errorf("a missing session directory = %v, %v; want no sessions and no error", list, err)
}
}
func TestSessionTitleIsTheFirstThingAsked(t *testing.T) {
long := strings.Repeat("a", 200)
cases := []struct {
name string
s Session
want string
}{
{"empty", Session{}, ""},
{"first user line", Session{Messages: []llm.Message{
{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("hello")}},
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("\n\n add a resume command \nand tests")}},
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("later")}},
}}, "add a resume command"},
{"tool results are not what anyone said", Session{Messages: []llm.Message{
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.ToolResultBlock("tu_1", "some output", false)}},
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("what went wrong?")}},
}}, "what went wrong?"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.s.Title(); got != c.want {
t.Errorf("Title() = %q, want %q", got, c.want)
}
})
}
s := Session{Messages: []llm.Message{{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(long)}}}}
title := s.Title()
if len([]rune(title)) != titleMax || !strings.HasSuffix(title, "…") {
t.Errorf("a long prompt was not capped: %d runes, %q", len([]rune(title)), title)
}
}
// The worktree a session's work had moved into is part of the session: it is
// what sends the work back there when the conversation is reopened. The
// directory the session is filed under stays the main checkout, so the
// chooser there still lists it.
func TestSessionRemembersItsWorktree(t *testing.T) {
dir := t.TempDir()
a := New(nil, nil, NewState(), nil, Options{})
if err := a.Save(dir, "s", "/repo", "/trees/dark-mode", "m"); err != nil {
t.Fatal(err)
}
got, err := LoadSession(filepath.Join(dir, "s.json"))
if err != nil {
t.Fatal(err)
}
if got.Worktree != "/trees/dark-mode" {
t.Errorf("Worktree = %q, want the tree it moved into", got.Worktree)
}
if got.Root != "/repo" {
t.Errorf("Root = %q, want the checkout it is filed under", got.Root)
}
if !got.BelongsTo("/repo") {
t.Error("a session whose work moved no longer belongs to the checkout it is filed in")
}
}
// A session that never left is unchanged on disk, so nothing that reads these
// files has to cope with a new key appearing where there was none.
func TestSessionWithoutAWorktreeOmitsTheField(t *testing.T) {
dir := t.TempDir()
a := New(nil, nil, NewState(), nil, Options{})
if err := a.Save(dir, "s", "/repo", "", "m"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, "s.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "worktree") {
t.Errorf("session file mentions a worktree it never had:\n%s", b)
}
}