661 lines
22 KiB
Go
661 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
type stubProvider struct{}
|
|
|
|
func (stubProvider) Name() string { return "stub" }
|
|
|
|
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
|
ch := make(chan provider.Chunk, 1)
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func controllerWithContent(t *testing.T, path string) *control.Controller {
|
|
t.Helper()
|
|
sess := agent.NewSession("system")
|
|
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
|
|
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
|
|
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
|
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
|
|
}
|
|
|
|
func waitForFile(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("session file %q never contained %q", path, want)
|
|
}
|
|
|
|
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
|
t.Helper()
|
|
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
|
}
|
|
|
|
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
tab.saveMu.Lock()
|
|
idle := !tab.saving && !tab.saveAgain
|
|
tab.saveMu.Unlock()
|
|
if idle {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("autosave loop did not become idle")
|
|
}
|
|
|
|
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
|
t.Helper()
|
|
ctrl := controllerWithContent(t, path)
|
|
tab := &WorkspaceTab{
|
|
ID: "test_tab",
|
|
Ctrl: ctrl,
|
|
Scope: "global",
|
|
WorkspaceRoot: "",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
|
|
a := &App{
|
|
tabs: map[string]*WorkspaceTab{"test_tab": tab},
|
|
activeTabID: "test_tab",
|
|
}
|
|
tab.sink.app = a
|
|
return a, tab
|
|
}
|
|
|
|
// TestTurnDonePersistsSession proves a completed turn is written to disk without
|
|
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
|
|
// nil sink ctx (no webview) must not disable persistence.
|
|
func TestTurnDonePersistsSession(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
_, tab := appWithTab(t, path)
|
|
|
|
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
|
|
waitForFile(t, path, "remember this turn")
|
|
waitForAutosaveIdle(t, tab)
|
|
}
|
|
|
|
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
|
// per-token event storm doesn't thrash the disk.
|
|
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
a, tab := appWithTab(t, path)
|
|
_ = a
|
|
|
|
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
|
|
}
|
|
}
|
|
|
|
// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
|
|
// single-flight loop neither panics nor drops the final write.
|
|
func TestScheduleSnapshotCoalesces(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
a, tab := appWithTab(t, path)
|
|
_ = a
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 64; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
waitForFile(t, path, "acknowledged")
|
|
waitForAutosaveIdle(t, tab)
|
|
}
|
|
|
|
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
|
if err := os.Mkdir(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir blocked path: %v", err)
|
|
}
|
|
a, tab := appWithTab(t, path)
|
|
_ = a
|
|
|
|
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
|
|
|
tab.saveMu.Lock()
|
|
failures := tab.saveFailures
|
|
tab.saveMu.Unlock()
|
|
if failures == 0 {
|
|
t.Fatal("autosave failure should be recorded and retried")
|
|
}
|
|
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
|
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
|
}
|
|
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatalf("remove blocked dir: %v", err)
|
|
}
|
|
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
waitForFile(t, path, "remember this turn")
|
|
waitForAutosaveIdle(t, tab)
|
|
|
|
tab.saveMu.Lock()
|
|
failures = tab.saveFailures
|
|
tab.saveMu.Unlock()
|
|
if failures != 0 {
|
|
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
|
}
|
|
}
|
|
|
|
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
root := globalTabWorkspaceRoot()
|
|
dir := desktopSessionDir(root)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
originalPath := filepath.Join(dir, "session.jsonl")
|
|
originalTopic := "topic_original"
|
|
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
|
t.Fatalf("set original topic title: %v", err)
|
|
}
|
|
current := agent.NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
|
if err := current.Save(originalPath); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
|
Scope: "global",
|
|
TopicID: originalTopic,
|
|
TopicTitle: "Original",
|
|
Preview: "first",
|
|
Turns: 2,
|
|
SchemaVersion: agent.BranchMetaCountsVersion,
|
|
}); err != nil {
|
|
t.Fatalf("SaveBranchMeta original: %v", err)
|
|
}
|
|
|
|
staleSess := agent.NewSession("sys")
|
|
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
|
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{},
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
activeTabID: "recovery_tab",
|
|
}
|
|
tab := &WorkspaceTab{
|
|
ID: "recovery_tab",
|
|
Scope: "global",
|
|
WorkspaceRoot: root,
|
|
TopicID: originalTopic,
|
|
TopicTitle: "Original",
|
|
SessionPath: originalPath,
|
|
Ready: true,
|
|
model: "test-model",
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
tab.Ctrl = control.New(control.Options{
|
|
Executor: staleExec,
|
|
SessionDir: dir,
|
|
SessionPath: originalPath,
|
|
Label: "test",
|
|
Sink: tab.sink,
|
|
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
|
})
|
|
app.tabs[tab.ID] = tab
|
|
|
|
if err := tab.Ctrl.Snapshot(); err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
recoveryPath := tab.Ctrl.SessionPath()
|
|
if recoveryPath == "" || recoveryPath == originalPath {
|
|
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
|
}
|
|
if tab.SessionPath != recoveryPath {
|
|
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
|
}
|
|
if tab.TopicID != originalTopic {
|
|
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
|
}
|
|
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
|
}
|
|
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
|
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
|
}
|
|
tabMeta := app.tabMeta(tab, true)
|
|
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
|
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
foundOriginal := false
|
|
var walk func([]ProjectNode)
|
|
walk = func(list []ProjectNode) {
|
|
for _, node := range list {
|
|
if node.Recovered {
|
|
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
|
}
|
|
if node.TopicID == originalTopic {
|
|
foundOriginal = true
|
|
}
|
|
walk(node.Children)
|
|
}
|
|
}
|
|
walk(nodes)
|
|
if !foundOriginal {
|
|
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
|
}
|
|
}
|
|
|
|
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
root := globalTabWorkspaceRoot()
|
|
dir := desktopSessionDir(root)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
originalPath := filepath.Join(dir, "session.jsonl")
|
|
current := agent.NewSession("sys")
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
|
if err := current.Save(originalPath); err != nil {
|
|
t.Fatalf("Save current: %v", err)
|
|
}
|
|
|
|
staleSess := agent.NewSession("sys")
|
|
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
|
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
|
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
|
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
|
OriginalPath: originalPath,
|
|
BranchMeta: agent.BranchMeta{
|
|
Name: agent.RecoveryBranchDefaultName,
|
|
Scope: "global",
|
|
TopicID: "topic_recovery",
|
|
TopicTitle: "Recovery",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveRecoveryBranch: %v", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
|
}
|
|
defer lease.Release()
|
|
|
|
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
|
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
|
app := &App{
|
|
ctx: context.Background(),
|
|
tabs: map[string]*WorkspaceTab{},
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
activeTabID: "recovery_tab",
|
|
}
|
|
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
|
runtimeEvents <- runtimeEventEnvelope{
|
|
ctx: ctx,
|
|
name: name,
|
|
payload: append([]interface{}(nil), payload...),
|
|
}
|
|
}
|
|
tab := &WorkspaceTab{
|
|
ID: "recovery_tab",
|
|
Scope: "global",
|
|
WorkspaceRoot: root,
|
|
TopicID: "topic_original",
|
|
TopicTitle: "Original",
|
|
SessionPath: originalPath,
|
|
Ready: true,
|
|
model: "test-model",
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
tab.Ctrl = control.New(control.Options{
|
|
Executor: staleExec,
|
|
SessionDir: dir,
|
|
SessionPath: originalPath,
|
|
Label: "test",
|
|
Sink: tab.sink,
|
|
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
|
})
|
|
app.tabs[tab.ID] = tab
|
|
|
|
err = tab.Ctrl.Snapshot()
|
|
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
|
}
|
|
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
|
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
|
}
|
|
if tab.SessionPath != originalPath {
|
|
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
|
}
|
|
if tab.TopicID != "topic_original" {
|
|
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
|
}
|
|
|
|
deadline := time.After(time.Second)
|
|
for {
|
|
select {
|
|
case emitted := <-runtimeEvents:
|
|
if emitted.name != "session:recovery-failed" {
|
|
continue
|
|
}
|
|
if len(emitted.payload) != 1 {
|
|
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
|
}
|
|
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
|
if !ok {
|
|
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
|
}
|
|
if failed.Reason != "lease_held" {
|
|
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
|
}
|
|
return
|
|
case <-deadline:
|
|
t.Fatal("session:recovery-failed event was not emitted")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
|
if err := os.Mkdir(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir blocked path: %v", err)
|
|
}
|
|
a, _ := appWithTab(t, path)
|
|
a.tabs["target_tab"] = &WorkspaceTab{
|
|
ID: "target_tab",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
a.tabOrder = []string{"test_tab", "target_tab"}
|
|
|
|
err := a.SetActiveTab("target_tab")
|
|
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
|
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
|
}
|
|
if a.activeTabID != "test_tab" {
|
|
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
|
}
|
|
}
|
|
|
|
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
|
if err := os.Mkdir(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir blocked path: %v", err)
|
|
}
|
|
a, tab := appWithTab(t, path)
|
|
target := filepath.Join(t.TempDir(), "target.jsonl")
|
|
sess := agent.NewSession("system")
|
|
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
|
if err := sess.Save(target); err != nil {
|
|
t.Fatalf("save target: %v", err)
|
|
}
|
|
loaded, err := agent.LoadSession(target)
|
|
if err != nil {
|
|
t.Fatalf("load target: %v", err)
|
|
}
|
|
|
|
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
|
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
|
t.Fatalf("rebind error = %v, want persistence failure", err)
|
|
}
|
|
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
|
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
|
}
|
|
}
|
|
|
|
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
|
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
|
// longer write the session file — even when it is in flight at the moment the
|
|
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
|
|
// captured session path, so its Snapshot() call landed after DeleteSession
|
|
// trashed the file, "resurrecting" it.
|
|
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
|
|
doomed, doomedTab := appWithTab(t, path)
|
|
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
|
|
survivor := &WorkspaceTab{
|
|
ID: "survivor_tab",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
|
|
doomed.tabs["survivor_tab"] = survivor
|
|
doomed.activeTabID = "test_tab"
|
|
|
|
// Write the session file once via the autosave loop, then wait for idle so
|
|
// the next TurnDone reliably kicks off a fresh loop.
|
|
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
waitForFile(t, path, "acknowledged")
|
|
waitForAutosaveIdle(t, doomedTab)
|
|
|
|
// Kick the autosave loop and close the tab in close succession. The loop
|
|
// will be in flight when CloseTab runs — exactly the #4384 window.
|
|
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
if err := doomed.CloseTab("test_tab"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
|
|
// CloseTab must have returned only after the autosave loop finished. Remove
|
|
// the file the way DeleteSession would (move to trash is just a remove here
|
|
// since we only care that nothing rewrites the original path).
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
t.Fatalf("remove session file: %v", err)
|
|
}
|
|
|
|
// Give any would-be resurrection a chance to strike. If the autosave loop
|
|
// were still alive (the bug), the file reappears here.
|
|
time.Sleep(100 * time.Millisecond)
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
|
|
}
|
|
|
|
// And the controller's session path must be cleared so no future Snapshot
|
|
// can write either.
|
|
if got := doomedTab.Ctrl.SessionPath(); got != "" {
|
|
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
|
|
}
|
|
}
|
|
|
|
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
|
if err := os.Mkdir(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir blocked path: %v", err)
|
|
}
|
|
a, tab := appWithTab(t, path)
|
|
survivor := &WorkspaceTab{
|
|
ID: "survivor_tab",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
|
a.tabs[survivor.ID] = survivor
|
|
a.tabOrder = []string{tab.ID, survivor.ID}
|
|
|
|
err := a.CloseTab(tab.ID)
|
|
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
|
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
|
}
|
|
if _, ok := a.tabs[tab.ID]; !ok {
|
|
t.Fatal("tab was removed even though its session could not be saved")
|
|
}
|
|
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
|
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
|
}
|
|
}
|
|
|
|
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
|
// closing/drain logic is per-tab and must not leak to other tabs.
|
|
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
|
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
|
|
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
|
|
|
|
a, _ := appWithTab(t, doomedPath)
|
|
survivorCtrl := controllerWithContent(t, survivorPath)
|
|
survivor := &WorkspaceTab{
|
|
ID: "survivor_tab",
|
|
Ctrl: survivorCtrl,
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
|
a.tabs["survivor_tab"] = survivor
|
|
a.activeTabID = "test_tab"
|
|
|
|
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
|
|
waitForFile(t, survivorPath, "acknowledged")
|
|
waitForAutosaveIdle(t, survivor)
|
|
|
|
if err := a.CloseTab("test_tab"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
|
|
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
|
|
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
|
|
}
|
|
if survivor.closing {
|
|
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
|
|
}
|
|
}
|
|
|
|
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "delete-open.jsonl")
|
|
ctrl := controllerWithContent(t, path)
|
|
tab := &WorkspaceTab{
|
|
ID: "delete_open",
|
|
Scope: "global",
|
|
Ready: true,
|
|
Ctrl: ctrl,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"delete_open": tab},
|
|
activeTabID: "delete_open",
|
|
}
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
|
|
if err := app.DeleteSession(path); err != nil {
|
|
t.Fatalf("DeleteSession: %v", err)
|
|
}
|
|
|
|
if got := ctrl.SessionPath(); got != "" {
|
|
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("session should be in trash: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_clear_removed_runtime"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "trash-open-topic.jsonl")
|
|
ctrl := controllerWithContent(t, path)
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
UpdatedAt: time.Now(),
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Clear removed runtime",
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
tab := &WorkspaceTab{
|
|
ID: "trash_open",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Clear removed runtime",
|
|
Ready: true,
|
|
Ctrl: ctrl,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
survivor := &WorkspaceTab{
|
|
ID: "survivor",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
|
|
tabOrder: []string{"trash_open", "survivor"},
|
|
activeTabID: "trash_open",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("TrashTopic: %v", err)
|
|
}
|
|
|
|
if got := ctrl.SessionPath(); got != "" {
|
|
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("topic session should be in trash: %v", err)
|
|
}
|
|
}
|