Files
2026-07-13 13:00:08 +08:00

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()
}