319 lines
11 KiB
Go
319 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/provider"
|
|
)
|
|
|
|
// TestResumeDispatchOpensPicker proves bare "/resume" writes the session list
|
|
// to the scrollback (above the input) AND opens the interactive picker overlay.
|
|
func TestResumeDispatchOpensPicker(t *testing.T) {
|
|
dir := t.TempDir()
|
|
saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")
|
|
saveTestSession(t, filepath.Join(dir, "b.jsonl"), "beta prompt")
|
|
|
|
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, Label: "test"})
|
|
|
|
if cmd := m.runSlashCommand("/resume"); cmd != nil {
|
|
t.Fatal("/resume should not return a tea.Cmd")
|
|
}
|
|
if m.resumePick == nil {
|
|
t.Fatal("bare /resume should open the picker")
|
|
}
|
|
if len(m.resumePick.sessions) != 2 {
|
|
t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.sessions))
|
|
}
|
|
// Session list must also appear in the scrollback transcript (above input).
|
|
out := strings.Join(m.transcript, "\n")
|
|
if !strings.Contains(out, "alpha prompt") || !strings.Contains(out, "beta prompt") {
|
|
t.Fatalf("scrollback should contain session previews:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestResumePickerNavigateAndSelect proves the picker's up/down navigation and
|
|
// Enter to resume the selected session.
|
|
func TestResumePickerNavigateAndSelect(t *testing.T) {
|
|
dir := t.TempDir()
|
|
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
|
|
// Create two saved sessions.
|
|
aPath := filepath.Join(dir, "a.jsonl")
|
|
saveTestSession(t, aPath, "first session prompt")
|
|
bPath := filepath.Join(dir, "b.jsonl")
|
|
saveTestSession(t, bPath, "SECOND-SESSION-PROMPT")
|
|
// Pin distinct mtimes so b is unambiguously the most recent. Created back to
|
|
// back, the two files can land in the same filesystem mtime tick (seen on the
|
|
// CI Windows runner), which then tie-breaks to a.jsonl by path and flakes.
|
|
now := time.Now()
|
|
if err := os.Chtimes(aPath, now.Add(-2*time.Second), now.Add(-2*time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(bPath, now, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m := newTestChatTUI()
|
|
m.width = 80
|
|
m.ctrl = ctrl
|
|
|
|
// Open the picker via bare /resume.
|
|
m.runSlashCommand("/resume")
|
|
if m.resumePick == nil {
|
|
t.Fatal("bare /resume should open the picker")
|
|
}
|
|
if len(m.resumePick.sessions) != 2 {
|
|
t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.sessions))
|
|
}
|
|
|
|
// The first session (default selection) is the most recent, which is b.jsonl.
|
|
// Press Enter to resume it.
|
|
next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
m = next.(chatTUI)
|
|
|
|
if got := ctrl.SessionPath(); got != bPath {
|
|
t.Fatalf("session path = %q, want %q", got, bPath)
|
|
}
|
|
if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "SECOND-SESSION-PROMPT") {
|
|
t.Fatalf("transcript should replay the resumed session:\n%s", out)
|
|
}
|
|
if m.resumePick != nil {
|
|
t.Fatal("picker should close after resume")
|
|
}
|
|
}
|
|
|
|
// TestResumePickerEscDismisses proves pressing Esc closes the picker without
|
|
// switching sessions.
|
|
func TestResumePickerEscDismisses(t *testing.T) {
|
|
dir := t.TempDir()
|
|
saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")
|
|
|
|
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
|
m := newTestChatTUI()
|
|
m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
|
|
m.runSlashCommand("/resume")
|
|
if m.resumePick == nil {
|
|
t.Fatal("bare /resume should open the picker")
|
|
}
|
|
|
|
next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEsc})
|
|
m = next.(chatTUI)
|
|
if m.resumePick != nil {
|
|
t.Fatal("picker should close on Esc")
|
|
}
|
|
}
|
|
|
|
// TestResumeDispatchSwitchesAndReplays drives "/resume <n>" through the slash
|
|
// dispatcher and asserts the controller switched session AND the resumed
|
|
// transcript was replayed into the scrollback.
|
|
func TestResumeDispatchSwitchesAndReplays(t *testing.T) {
|
|
dir := t.TempDir()
|
|
active := agent.NewSession("sys")
|
|
active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
|
|
exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
ctrl.SetSessionPath(filepath.Join(dir, "active.jsonl"))
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
otherPath := filepath.Join(dir, "other.jsonl")
|
|
saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")
|
|
|
|
m := newTestChatTUI()
|
|
m.width = 80
|
|
m.ctrl = ctrl
|
|
|
|
target := 0
|
|
for i, s := range recentSessions(dir) {
|
|
if s.Path == otherPath {
|
|
target = i + 1
|
|
}
|
|
}
|
|
if target == 0 {
|
|
t.Fatal("other session not listed by recentSessions")
|
|
}
|
|
|
|
m.runSlashCommand("/resume " + strconv.Itoa(target))
|
|
|
|
if got := ctrl.SessionPath(); got != otherPath {
|
|
t.Fatalf("session path = %q, want %q", got, otherPath)
|
|
}
|
|
if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "OTHER-SESSION-PROMPT") {
|
|
t.Fatalf("transcript should replay the resumed session:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestResumeWhileScrolledUpPinsViewportToBottom covers the session-switch
|
|
// regression where a stale scroll offset was preserved if the user had read
|
|
// back in the old transcript before resuming another session.
|
|
func TestResumeWhileScrolledUpPinsViewportToBottom(t *testing.T) {
|
|
dir := t.TempDir()
|
|
active := agent.NewSession("sys")
|
|
for i := 0; i < 18; i++ {
|
|
active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt " + strconv.Itoa(i)})
|
|
}
|
|
exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
activePath := filepath.Join(dir, "active.jsonl")
|
|
ctrl.SetSessionPath(activePath)
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
otherPath := filepath.Join(dir, "other.jsonl")
|
|
saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")
|
|
|
|
target := 0
|
|
for i, s := range recentSessions(dir) {
|
|
if s.Path == otherPath {
|
|
target = i + 1
|
|
}
|
|
}
|
|
if target == 0 {
|
|
t.Fatal("other session not listed by recentSessions")
|
|
}
|
|
|
|
adv := func(m chatTUI, msg tea.Msg) chatTUI {
|
|
n, _ := m.Update(msg)
|
|
return n.(chatTUI)
|
|
}
|
|
|
|
cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 8})
|
|
if !cur.viewport.AtBottom() {
|
|
t.Fatal("initial resumed history should start at the bottom")
|
|
}
|
|
|
|
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
|
|
if cur.viewport.AtBottom() {
|
|
t.Fatal("wheel-up should move the old transcript away from the bottom")
|
|
}
|
|
|
|
cur.input.SetValue("/resume " + strconv.Itoa(target))
|
|
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
|
|
if got := ctrl.SessionPath(); got != otherPath {
|
|
t.Fatalf("session path = %q, want %q", got, otherPath)
|
|
}
|
|
out := strings.Join(cur.transcript, "\n")
|
|
if !strings.Contains(out, "OTHER-SESSION-PROMPT") {
|
|
t.Fatalf("transcript should replay the resumed session:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "active prompt") {
|
|
t.Fatalf("transcript should not retain the previous session after resume:\n%s", out)
|
|
}
|
|
if !cur.viewport.AtBottom() {
|
|
t.Fatalf("resume while scrolled up should pin to bottom, AtBottom=%v, YOffset=%d", cur.viewport.AtBottom(), cur.viewport.YOffset())
|
|
}
|
|
}
|
|
|
|
func saveTestSession(t *testing.T, path, prompt string) {
|
|
t.Helper()
|
|
s := agent.NewSession("sys")
|
|
s.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
|
|
if err := s.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestResumeArgCompletionListsSessions proves "/resume " opens an indexed menu
|
|
// of the saved sessions, mirroring the /switch branch completion.
|
|
func TestResumeArgCompletionListsSessions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")
|
|
saveTestSession(t, filepath.Join(dir, "b.jsonl"), "second")
|
|
|
|
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
|
m := newTestChatTUI()
|
|
m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
|
|
m.input.SetValue("/resume ")
|
|
m.updateCompletion()
|
|
if !m.completion.active || m.completion.kind != compSlashArg {
|
|
t.Fatalf("/resume should open argument completion: %+v", m.completion)
|
|
}
|
|
if got := labels(m.completion.items); len(got) != 2 || got[0] != "1" || got[1] != "2" {
|
|
t.Fatalf("resume completion = %v, want [1 2]", got)
|
|
}
|
|
}
|
|
|
|
// TestResumeAcceptChainsIntoSessionMenu proves accepting "/resume" (a
|
|
// non-descend command that still takes arguments) immediately opens the session
|
|
// menu, rather than waiting for the next keystroke.
|
|
func TestResumeAcceptChainsIntoSessionMenu(t *testing.T) {
|
|
dir := t.TempDir()
|
|
saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")
|
|
|
|
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
|
m := newTestChatTUI()
|
|
m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
|
|
m.input.SetValue("/resu")
|
|
m.updateCompletion()
|
|
m.acceptCompletion()
|
|
if got := m.input.Value(); got != "/resume " {
|
|
t.Fatalf("accepting /resume should fill %q, got %q", "/resume ", got)
|
|
}
|
|
if !m.completion.active || m.completion.kind != compSlashArg {
|
|
t.Fatalf("accepting /resume should chain into the session menu: %+v", m.completion)
|
|
}
|
|
}
|
|
|
|
// TestRunResumeSwitchesSession proves "/resume <n>" repoints the running
|
|
// controller to the chosen saved session and loads its history.
|
|
func TestRunResumeSwitchesSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
active := agent.NewSession("sys")
|
|
active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
|
|
exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
|
|
activePath := filepath.Join(dir, "active.jsonl")
|
|
ctrl.SetSessionPath(activePath)
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
otherPath := filepath.Join(dir, "other.jsonl")
|
|
saveTestSession(t, otherPath, "other prompt")
|
|
|
|
m := newTestChatTUI()
|
|
m.width = 80
|
|
m.ctrl = ctrl
|
|
|
|
target := 0
|
|
for i, s := range recentSessions(dir) {
|
|
if s.Path == otherPath {
|
|
target = i + 1
|
|
}
|
|
}
|
|
if target == 0 {
|
|
t.Fatal("saved session not listed by recentSessions")
|
|
}
|
|
|
|
m.runResumeCommand("/resume " + strconv.Itoa(target))
|
|
|
|
if got := ctrl.SessionPath(); got != otherPath {
|
|
t.Fatalf("session path = %q, want %q", got, otherPath)
|
|
}
|
|
hist := ctrl.History()
|
|
if len(hist) == 0 || hist[len(hist)-1].Content != "other prompt" {
|
|
t.Fatalf("history not loaded from target: %+v", hist)
|
|
}
|
|
}
|