326 lines
10 KiB
Go
326 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
// holdSessionLease simulates another runtime owning path for the duration of
|
|
// the test (the in-process lease registry plus the OS lock behave exactly as a
|
|
// foreign holder for acquisition purposes).
|
|
func holdSessionLease(t *testing.T, path string) *agent.SessionLease {
|
|
t.Helper()
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("test holder acquire: %v", err)
|
|
}
|
|
t.Cleanup(lease.Release)
|
|
return lease
|
|
}
|
|
|
|
func TestRunResumeRefusedWhenSessionLeaseHeld(t *testing.T) {
|
|
isolateCLIConfigHome(t)
|
|
|
|
path := filepath.Join(t.TempDir(), "held-run.jsonl")
|
|
saveTestSession(t, path, "held prompt")
|
|
holdSessionLease(t, path)
|
|
|
|
errOut := captureStderr(t, func() {
|
|
if rc := runAgent([]string{"--resume", path, "continue task"}); rc != 1 {
|
|
t.Fatalf("run --resume held rc = %d, want 1", rc)
|
|
}
|
|
})
|
|
if !strings.Contains(errOut, "in use by another Reasonix") {
|
|
t.Fatalf("run --resume held stderr = %q, want holder wording", errOut)
|
|
}
|
|
if !strings.Contains(errOut, "--copy") {
|
|
t.Fatalf("run --resume held stderr = %q, want --copy guidance", errOut)
|
|
}
|
|
if strings.Contains(errOut, path) {
|
|
t.Fatalf("run --resume held stderr leaks the session path: %q", errOut)
|
|
}
|
|
}
|
|
|
|
func TestRunCopyRequiresResumeTarget(t *testing.T) {
|
|
isolateCLIConfigHome(t)
|
|
|
|
errOut := captureStderr(t, func() {
|
|
if rc := runAgent([]string{"--copy", "do things"}); rc != 2 {
|
|
t.Fatalf("run --copy without target rc = %d, want 2", rc)
|
|
}
|
|
})
|
|
if !strings.Contains(errOut, "--copy requires --resume or --continue") {
|
|
t.Fatalf("run --copy stderr = %q, want usage error", errOut)
|
|
}
|
|
}
|
|
|
|
func TestRunResumeCopyContinuesInDuplicate(t *testing.T) {
|
|
isolateCLIConfigHome(t)
|
|
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "held-src.jsonl")
|
|
saveTestSession(t, src, "copy me")
|
|
srcBytes, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
holdSessionLease(t, src)
|
|
|
|
var rc int
|
|
out := captureStdout(t, func() {
|
|
_ = captureStderr(t, func() {
|
|
rc = runAgent([]string{"--resume", src, "--copy", "continue task"})
|
|
})
|
|
})
|
|
// The isolated home has no provider config, so the run itself fails after
|
|
// the copy — but never with the lease refusal, and never touching src.
|
|
if rc != 1 {
|
|
t.Fatalf("run --resume --copy rc = %d, want 1 (setup fails in isolated home)", rc)
|
|
}
|
|
if !strings.Contains(out, "continuing in a session copy: ") {
|
|
t.Fatalf("stdout = %q, want session copy line", out)
|
|
}
|
|
copyPath := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "continuing in a session copy:"))
|
|
if copyPath == "" || copyPath == src {
|
|
t.Fatalf("copy path = %q, want a fresh path", copyPath)
|
|
}
|
|
|
|
srcAfter, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(srcAfter) != string(srcBytes) {
|
|
t.Fatalf("source transcript was modified by --copy")
|
|
}
|
|
srcLoaded, err := agent.LoadSession(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyLoaded, err := agent.LoadSession(copyPath)
|
|
if err != nil {
|
|
t.Fatalf("load copy: %v", err)
|
|
}
|
|
srcMsgs, copyMsgs := srcLoaded.Snapshot(), copyLoaded.Snapshot()
|
|
if len(copyMsgs) != len(srcMsgs) {
|
|
t.Fatalf("copy has %d messages, source %d", len(copyMsgs), len(srcMsgs))
|
|
}
|
|
for i := range srcMsgs {
|
|
if copyMsgs[i].Role != srcMsgs[i].Role || copyMsgs[i].Content != srcMsgs[i].Content {
|
|
t.Fatalf("copy message %d = %+v, want %+v", i, copyMsgs[i], srcMsgs[i])
|
|
}
|
|
}
|
|
// The run exited: the copy's lease must be released again.
|
|
if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(copyPath))); !os.IsNotExist(err) {
|
|
t.Fatalf("copy lease info after exit stat err = %v, want not exist", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(copyPath)
|
|
if err != nil {
|
|
t.Fatalf("copy lease not released after run: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestRunResumeReleasesLeaseOnExit(t *testing.T) {
|
|
isolateCLIConfigHome(t)
|
|
|
|
path := filepath.Join(t.TempDir(), "release-run.jsonl")
|
|
saveTestSession(t, path, "resume me")
|
|
|
|
// No provider config in the isolated home: the run fails after the lease
|
|
// was taken, and the deferred release must still run.
|
|
_ = captureStderr(t, func() {
|
|
if rc := runAgent([]string{"--resume", path, "continue task"}); rc != 1 {
|
|
t.Fatalf("run --resume rc = %d, want 1 (setup fails in isolated home)", rc)
|
|
}
|
|
})
|
|
if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(path))); !os.IsNotExist(err) {
|
|
t.Fatalf("lease info after run exit stat err = %v, want not exist", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("lease not released after run exit: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestCopySessionForWritingDuplicatesTranscript(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "src.jsonl")
|
|
s := agent.NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
|
|
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
|
|
if err := s.Save(src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.SaveBranchMeta(src, agent.BranchMeta{
|
|
CustomTitle: "My debugging session",
|
|
Model: "deepseek/deepseek-chat",
|
|
SchemaVersion: agent.BranchMetaCountsVersion,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srcBytes, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
copyPath, err := copySessionForWriting(src)
|
|
if err != nil {
|
|
t.Fatalf("copySessionForWriting: %v", err)
|
|
}
|
|
if filepath.Dir(copyPath) != dir {
|
|
t.Fatalf("copy landed in %q, want beside the source in %q", filepath.Dir(copyPath), dir)
|
|
}
|
|
|
|
loaded, err := agent.LoadSession(copyPath)
|
|
if err != nil {
|
|
t.Fatalf("load copy: %v", err)
|
|
}
|
|
got := loaded.Snapshot()
|
|
want := s.Snapshot()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("copy has %d messages, want %d", len(got), len(want))
|
|
}
|
|
for i := range want {
|
|
if got[i].Role != want[i].Role || got[i].Content != want[i].Content {
|
|
t.Fatalf("copy message %d = %+v, want %+v", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
meta, ok, err := agent.LoadBranchMeta(copyPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("copy branch meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.ParentID != agent.BranchID(src) {
|
|
t.Fatalf("copy ParentID = %q, want %q", meta.ParentID, agent.BranchID(src))
|
|
}
|
|
if meta.CustomTitle != "My debugging session (copy)" {
|
|
t.Fatalf("copy CustomTitle = %q", meta.CustomTitle)
|
|
}
|
|
if meta.Model != "deepseek/deepseek-chat" {
|
|
t.Fatalf("copy Model = %q", meta.Model)
|
|
}
|
|
|
|
// The copy starts unowned and without lease/lock sidecars of its own.
|
|
for _, sidecar := range []string{
|
|
store.SessionLeaseInfo(agent.CanonicalSessionPath(copyPath)),
|
|
store.SessionLeaseLock(agent.CanonicalSessionPath(copyPath)),
|
|
} {
|
|
if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
|
|
t.Fatalf("copy has lease sidecar %s (err=%v)", sidecar, err)
|
|
}
|
|
}
|
|
// The source transcript is only read.
|
|
srcAfter, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(srcAfter) != string(srcBytes) {
|
|
t.Fatalf("source transcript was modified by the copy")
|
|
}
|
|
}
|
|
|
|
// chatLeaseFixture builds a TUI over a temp session dir with two saved
|
|
// sessions: older (the active one) and newer (the /resume 1 target).
|
|
func chatLeaseFixture(t *testing.T) (m chatTUI, active, target string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
active = filepath.Join(dir, "a-active.jsonl")
|
|
target = filepath.Join(dir, "b-target.jsonl")
|
|
saveTestSession(t, active, "active session")
|
|
saveTestSession(t, target, "target session")
|
|
pinNewer(t, active, target)
|
|
|
|
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
|
m = newTestChatTUI()
|
|
m.width = 80
|
|
m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: active, Label: "test"})
|
|
m.leases = control.NewSessionLeaseKeeper()
|
|
t.Cleanup(m.leases.Release)
|
|
if err := m.leases.Rebind(active); err != nil {
|
|
t.Fatalf("seed lease on active: %v", err)
|
|
}
|
|
return m, active, target
|
|
}
|
|
|
|
// pinNewer gives newer a strictly later mtime than older so ListSessions
|
|
// ordering is deterministic across filesystems with coarse mtimes.
|
|
func pinNewer(t *testing.T, older, newer string) {
|
|
t.Helper()
|
|
info, err := os.Stat(newer)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := info.ModTime().Add(-2 * time.Second)
|
|
if err := os.Chtimes(older, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestChatResumeCommandRefusedWhenLeaseHeld(t *testing.T) {
|
|
m, active, target := chatLeaseFixture(t)
|
|
holdSessionLease(t, target)
|
|
|
|
m.runResumeCommand("/resume 1")
|
|
|
|
out := strings.Join(m.transcript, "\n")
|
|
if !strings.Contains(out, "in use by another Reasonix") {
|
|
t.Fatalf("refusal notice missing from transcript:\n%s", out)
|
|
}
|
|
if got := m.ctrl.SessionPath(); got != active {
|
|
t.Fatalf("session path after refused /resume = %q, want %q", got, active)
|
|
}
|
|
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(active); got != want {
|
|
t.Fatalf("lease after refused /resume = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestChatResumeCommandMovesLease(t *testing.T) {
|
|
m, active, target := chatLeaseFixture(t)
|
|
|
|
m.runResumeCommand("/resume 1")
|
|
|
|
if got := m.ctrl.SessionPath(); got != target {
|
|
t.Fatalf("session path after /resume = %q, want %q", got, target)
|
|
}
|
|
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(target); got != want {
|
|
t.Fatalf("lease after /resume = %q, want %q", got, want)
|
|
}
|
|
// The lease on the session we left must be free again.
|
|
lease, err := agent.TryAcquireSessionLease(active)
|
|
if err != nil {
|
|
t.Fatalf("old session lease not released by /resume: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestChatNewSessionTakesFreshLease(t *testing.T) {
|
|
m, active, _ := chatLeaseFixture(t)
|
|
|
|
if cmd := m.runSlashCommand("/new"); cmd != nil {
|
|
t.Fatal("/new should not return a tea.Cmd")
|
|
}
|
|
|
|
fresh := m.ctrl.SessionPath()
|
|
if fresh == active {
|
|
t.Fatalf("/new did not rotate the session path")
|
|
}
|
|
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(fresh); got != want {
|
|
t.Fatalf("lease after /new = %q, want %q", got, want)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(active)
|
|
if err != nil {
|
|
t.Fatalf("old session lease not released by /new: %v", err)
|
|
}
|
|
lease.Release()
|
|
}
|