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

479 lines
18 KiB
Go

package cli
import (
"fmt"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// divergedSessionController builds a controller whose in-memory transcript has
// diverged from what path holds on disk, so its next Snapshot hits a conflict
// and retargets the controller to a recovery branch.
func divergedSessionController(t *testing.T, dir, path string) *control.Controller {
t.Helper()
disk := agent.NewSession("sys prompt")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := disk.Save(path); err != nil {
t.Fatalf("save disk session: %v", err)
}
stale := agent.NewSession("sys prompt")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
return control.New(control.Options{
Executor: agent.New(nil, nil, stale, agent.Options{}, event.Discard),
SessionDir: dir,
SessionPath: path,
Label: "deepseek-flash",
})
}
// TestModelSwitchCarriesRecoveryPathAfterSnapshotConflict is the TUI /model
// twin of the desktop rebuild fix: when the pre-switch Snapshot retargets the
// controller to a recovery branch, the resume path handed to buildController
// must be that recovery path. A pre-snapshot capture bound the just-recovered
// transcript back to the original file, re-conflicting on every later save.
func TestModelSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
isolateUserConfig(t)
dir := t.TempDir()
originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, originalPath)
m.modelRef = "old/old-model"
var gotResumePath string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string) (*control.Controller, error) {
gotResumePath = resumePath
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
if m.pendingModelSwitch == nil {
t.Fatal("runModelSubcommand did not queue a model switch")
}
m.pendingModelSwitch()
if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
}
if got := m.ctrl.SessionPath(); got != gotResumePath {
t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
}
}
// TestEffortSwitchCarriesRecoveryPathAfterSnapshotConflict covers the same
// contract for the TUI /effort rebuild path.
func TestEffortSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
isolateUserConfig(t)
dir := t.TempDir()
originalPath := filepath.Join(dir, "effort-switch-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, originalPath)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
var gotResumePath string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string) (*control.Controller, error) {
gotResumePath = resumePath
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
cmd := m.runEffortCommand("/effort max")
if cmd == nil {
t.Fatal("runEffortCommand did not queue a rebuild")
}
cmd()
if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
}
if got := m.ctrl.SessionPath(); got != gotResumePath {
t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
}
}
// TestSkillRefreshCarriesRecoveryPathAfterSnapshotConflict covers the TUI skill
// rebuild path, which also snapshots then rebuilds the controller in place.
func TestSkillRefreshCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
dir := t.TempDir()
originalPath := filepath.Join(dir, "skill-refresh-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, originalPath)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
var gotResumePath string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string) (*control.Controller, error) {
gotResumePath = resumePath
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
if !m.scheduleSkillSessionRefresh("skill refresh", "") {
t.Fatal("scheduleSkillSessionRefresh did not queue a rebuild")
}
m.pendingModelSwitch()
if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
}
if got := m.ctrl.SessionPath(); got != gotResumePath {
t.Fatalf("old controller session path = %q, want recovery path %q", got, gotResumePath)
}
}
func TestWorkModeSwitchCarriesRecoveryPathAndMovesLeaseBeforeRebuild(t *testing.T) {
dir := t.TempDir()
originalPath := filepath.Join(dir, "work-mode-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, originalPath)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.runtimeProfile = "full"
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(originalPath); err != nil {
t.Fatalf("seed active lease: %v", err)
}
var gotResumePath, heldAtBuild string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string) (*control.Controller, error) {
gotResumePath = resumePath
heldAtBuild = m.leases.HeldPath()
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
cmd := m.runWorkModeCommand("/work-mode delivery")
if cmd == nil {
t.Fatal("work-mode switch did not queue a rebuild")
}
cmd()
if gotResumePath == "" || gotResumePath == originalPath || !strings.Contains(filepath.Base(gotResumePath), "-recovery-") {
t.Fatalf("resume path = %q, want recovery path distinct from %q", gotResumePath, originalPath)
}
if got, want := heldAtBuild, agent.CanonicalSessionPath(gotResumePath); got != want {
t.Fatalf("lease at build = %q, want recovery path %q", got, want)
}
}
func TestResumeCommandKeepsLeaseOnRecoveryPathWhenTargetHeld(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "resume-active-conflict.jsonl")
target := filepath.Join(dir, "resume-target.jsonl")
saveTestSession(t, target, "target session")
m := newTestChatTUI()
m.width = 80
m.ctrl = divergedSessionController(t, dir, active)
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
holdSessionLease(t, target)
m.runResumeCommand(fmt.Sprintf("/resume %d", resumeIndexForPath(t, dir, target)))
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || recoveryPath == target || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after refused resume = %q, want recovery path distinct from active %q and target %q", recoveryPath, active, target)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after refused resume = %q, want recovery path %q", got, want)
}
}
func TestResumePickerKeepsLeaseOnRecoveryPathWhenTargetHeld(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "resume-picker-active-conflict.jsonl")
target := filepath.Join(dir, "resume-picker-target.jsonl")
saveTestSession(t, target, "target session")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.resumePick = &resumePicker{sessions: []agent.SessionInfo{{Path: target}}, sel: 0}
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
holdSessionLease(t, target)
next, _ := m.applyResumePick()
m = next.(chatTUI)
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || recoveryPath == target || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after refused picker resume = %q, want recovery path distinct from active %q and target %q", recoveryPath, active, target)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after refused picker resume = %q, want recovery path %q", got, want)
}
}
func TestCompactDoneKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "compact-active-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
next, _ := m.Update(compactDoneMsg{})
m = next.(chatTUI)
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after compact snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after compact snapshot = %q, want recovery path %q", got, want)
}
}
func TestBranchTreeKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "tree-active-conflict.jsonl")
m := newTestChatTUI()
m.width = 80
m.ctrl = divergedSessionController(t, dir, active)
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
m.showBranchTree()
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after tree snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after tree snapshot = %q, want recovery path %q", got, want)
}
}
func TestShutdownMessageSnapshotsCurrentController(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "shutdown-active-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
next, cmd := m.Update(tuiShutdownMsg{})
m = next.(chatTUI)
if cmd == nil {
t.Fatal("shutdown message should return tea.Quit")
}
if msg := cmd(); msg != (tea.QuitMsg{}) {
t.Fatalf("shutdown command = %T, want tea.QuitMsg", msg)
}
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after shutdown snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after shutdown snapshot = %q, want recovery path %q", got, want)
}
}
// TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers the
// /switch tab-completion path: listing branches snapshots the session, which
// can retarget the controller to a recovery branch even though no switch runs.
func TestBranchCompletionKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "completion-active-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
if _, _, ok := m.branchArgItems("/switch "); !ok {
t.Fatal("branchArgItems did not handle /switch completion")
}
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after completion snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after completion snapshot = %q, want recovery path %q", got, want)
}
}
// TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict covers
// the rebuild-failure branch: the pre-switch snapshot can retarget the kept
// controller to a recovery branch, and a failed build must not leave the lease
// on the stale original path.
func TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *testing.T) {
isolateUserConfig(t)
dir := t.TempDir()
active := filepath.Join(dir, "model-switch-failure-conflict.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.modelRef = "old/old-model"
m.buildController = func(controllerBuildSpec, []provider.Message, string) (*control.Controller, error) {
return nil, fmt.Errorf("build failed")
}
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
if m.pendingModelSwitch == nil {
t.Fatal("runModelSubcommand did not queue a model switch")
}
next, _ := m.Update(m.pendingModelSwitch())
m = next.(chatTUI)
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after failed switch = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(recoveryPath); got != want {
t.Fatalf("lease after failed switch = %q, want recovery path %q", got, want)
}
}
// TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild pins the lease-before-
// bind order: the rebuilt controller resumes prevPath for writing inside
// buildController, so the lease must already guard the retargeted path when
// the build starts, not only after modelSwitchMsg lands.
func TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
isolateUserConfig(t)
dir := t.TempDir()
active := filepath.Join(dir, "model-switch-lease-order.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.modelRef = "old/old-model"
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
var heldAtBuild string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) {
heldAtBuild = m.leases.HeldPath()
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
if m.pendingModelSwitch == nil {
t.Fatal("runModelSubcommand did not queue a model switch")
}
m.pendingModelSwitch()
assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
}
// TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild covers the same
// lease-before-bind order for the /effort rebuild path.
func TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
isolateUserConfig(t)
dir := t.TempDir()
active := filepath.Join(dir, "effort-switch-lease-order.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
var heldAtBuild string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) {
heldAtBuild = m.leases.HeldPath()
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
cmd := m.runEffortCommand("/effort max")
if cmd == nil {
t.Fatal("runEffortCommand did not queue a rebuild")
}
cmd()
assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
}
// TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild covers the same
// lease-before-bind order for the TUI skill rebuild path.
func TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "skill-refresh-lease-order.jsonl")
m := newTestChatTUI()
m.ctrl = divergedSessionController(t, dir, active)
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.leases = control.NewSessionLeaseKeeper()
t.Cleanup(m.leases.Release)
if err := m.leases.Rebind(active); err != nil {
t.Fatalf("seed active lease: %v", err)
}
var heldAtBuild string
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string) (*control.Controller, error) {
heldAtBuild = m.leases.HeldPath()
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
if !m.scheduleSkillSessionRefresh("skill refresh", "") {
t.Fatal("scheduleSkillSessionRefresh did not queue a rebuild")
}
m.pendingModelSwitch()
assertLeaseHeldRecoveryPathAtBuild(t, &m, active, heldAtBuild)
}
// assertLeaseHeldRecoveryPathAtBuild verifies that the snapshot retargeted the
// controller to a recovery branch and that the lease already guarded that
// branch when buildController ran.
func assertLeaseHeldRecoveryPathAtBuild(t *testing.T, m *chatTUI, active, heldAtBuild string) {
t.Helper()
recoveryPath := m.ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == active || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
t.Fatalf("session path after switch snapshot = %q, want recovery path distinct from active %q", recoveryPath, active)
}
if want := agent.CanonicalSessionPath(recoveryPath); heldAtBuild != want {
t.Fatalf("lease when build started = %q, want recovery path %q", heldAtBuild, want)
}
}
func resumeIndexForPath(t *testing.T, dir, path string) int {
t.Helper()
for i, session := range recentSessions(dir) {
if session.Path == path {
return i + 1
}
}
t.Fatalf("session %q not found in recent sessions", path)
return 0
}