800 lines
26 KiB
Go
800 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
func carryingController(carried []provider.Message, path string) *control.Controller {
|
|
sess := &agent.Session{}
|
|
sess.Replace(carried)
|
|
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
|
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
|
|
}
|
|
|
|
// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
|
|
// config change rebuilds the controller and carries the conversation forward. Each
|
|
// rebuild must keep writing to the same file, so a run of them leaves exactly one
|
|
// history entry — not a new identical duplicate per rebuild.
|
|
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := agent.NewSessionPath(dir, "model-a")
|
|
ctrl := controllerWithContent(t, path)
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
prevPath := ctrl.SessionPath()
|
|
carried := ctrl.History()
|
|
ctrl.Close()
|
|
|
|
newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
|
|
ctrl = carryingController(carried, newPath)
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ctrl.Close()
|
|
|
|
infos, err := agent.ListSessions(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(infos) != 1 {
|
|
paths := make([]string, len(infos))
|
|
for i, s := range infos {
|
|
paths[i] = filepath.Base(s.Path)
|
|
}
|
|
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
|
|
}
|
|
}
|
|
|
|
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
|
|
|
|
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
app := NewApp()
|
|
first, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.SessionPath == "" {
|
|
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
|
|
}
|
|
if _, err := os.Stat(first.SessionPath); err != nil {
|
|
t.Fatalf("pre-created blank session should exist: %v", err)
|
|
}
|
|
second, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
|
|
}
|
|
if tabs := app.ListTabs(); len(tabs) != 1 {
|
|
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
globalRoot := globalWorkspaceRoot()
|
|
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
|
|
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("global", "", "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
app.tabs["blank"] = &WorkspaceTab{
|
|
ID: "blank",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalRoot,
|
|
TopicID: topic.ID,
|
|
TopicTitle: defaultTopicTitle,
|
|
SessionPath: sessionPath,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabOrder = []string{"blank"}
|
|
app.activeTabID = "blank"
|
|
|
|
meta, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if meta.ID != "blank" {
|
|
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("global", "", "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
globalRoot := globalWorkspaceRoot()
|
|
dir := desktopSessionDir(globalRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
stubPath := filepath.Join(dir, "empty-stub.jsonl")
|
|
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
|
|
t.Fatalf("write empty stub: %v", err)
|
|
}
|
|
now := time.Now()
|
|
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
|
|
CreatedAt: now.Add(-time.Minute),
|
|
UpdatedAt: now,
|
|
Scope: "global",
|
|
WorkspaceRoot: globalRoot,
|
|
TopicID: topic.ID,
|
|
TopicTitle: defaultTopicTitle,
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
|
|
meta, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if meta.TopicID != topic.ID {
|
|
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
app := NewApp()
|
|
before := time.Now().UnixMilli()
|
|
meta, err := app.EnsureBlankTab("global", "")
|
|
after := time.Now().UnixMilli()
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
|
|
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
|
if createdAt < before || createdAt > after {
|
|
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
|
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
const topicID = "topic_20260704-104018_deadbeef"
|
|
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
|
t.Fatalf("prepend topic: %v", err)
|
|
}
|
|
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
|
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
|
}
|
|
|
|
app := NewApp()
|
|
meta, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if meta.TopicID != topicID {
|
|
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
|
}
|
|
|
|
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
|
if got := loadTopicCreatedAt("", topicID); got != expected {
|
|
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
|
}
|
|
}
|
|
|
|
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
|
|
|
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
first, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
|
|
}
|
|
if tabs := app.ListTabs(); len(tabs) != 1 {
|
|
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := robustTempDir(t)
|
|
projectB := robustTempDir(t)
|
|
if err := addProject(projectA, "Project A"); err != nil {
|
|
t.Fatalf("add project A: %v", err)
|
|
}
|
|
if err := addProject(projectB, "Project B"); err != nil {
|
|
t.Fatalf("add project B: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
first, err := app.EnsureBlankTab("project", projectA)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
|
}
|
|
tabA := waitForTabReady(t, app, first.ID)
|
|
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
|
}
|
|
|
|
second, err := app.EnsureBlankTab("project", projectB)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
|
}
|
|
if second.ID == first.ID {
|
|
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
|
}
|
|
tabB := waitForTabReady(t, app, second.ID)
|
|
|
|
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
|
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
|
}
|
|
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
|
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
|
}
|
|
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
|
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
|
}
|
|
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
|
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
|
}
|
|
sys := systemPromptFrom(tabB.Ctrl.History())
|
|
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
|
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
|
}
|
|
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
|
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
|
}
|
|
}
|
|
|
|
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := robustTempDir(t)
|
|
projectB := robustTempDir(t)
|
|
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
|
if err != nil {
|
|
t.Fatalf("create project A empty session: %v", err)
|
|
}
|
|
tab := &WorkspaceTab{
|
|
ID: "blank-project-b",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectB,
|
|
SessionPath: pathA,
|
|
}
|
|
|
|
if blankTabSessionPathHasNoContent(tab) {
|
|
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
|
}
|
|
}
|
|
|
|
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := robustTempDir(t)
|
|
projectB := robustTempDir(t)
|
|
if err := addProject(projectA, "Project A"); err != nil {
|
|
t.Fatalf("add project A: %v", err)
|
|
}
|
|
if err := addProject(projectB, "Project B"); err != nil {
|
|
t.Fatalf("add project B: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
first, err := app.EnsureBlankTab("project", projectA)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
|
}
|
|
waitForTabReady(t, app, first.ID)
|
|
|
|
second, err := app.EnsureBlankTab("project", projectB)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
|
}
|
|
tabB := waitForTabReady(t, app, second.ID)
|
|
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
|
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
|
|
|
forked, err := app.Fork(turn)
|
|
if err != nil {
|
|
t.Fatalf("Fork: %v", err)
|
|
}
|
|
if forked.ID == "" || forked.ID == second.ID {
|
|
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
|
}
|
|
forkTab := waitForTabReady(t, app, forked.ID)
|
|
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
|
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
|
}
|
|
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
|
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
|
}
|
|
sys := systemPromptFrom(forkTab.Ctrl.History())
|
|
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
|
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
|
}
|
|
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
|
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
|
}
|
|
}
|
|
|
|
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := robustTempDir(t)
|
|
projectB := robustTempDir(t)
|
|
if err := addProject(projectA, "Project A"); err != nil {
|
|
t.Fatalf("add project A: %v", err)
|
|
}
|
|
if err := addProject(projectB, "Project B"); err != nil {
|
|
t.Fatalf("add project B: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
first, err := app.EnsureBlankTab("project", projectA)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
|
}
|
|
waitForTabReady(t, app, first.ID)
|
|
|
|
second, err := app.EnsureBlankTab("project", projectB)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
|
}
|
|
tabB := waitForTabReady(t, app, second.ID)
|
|
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
|
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
|
|
|
if err := app.Rewind(turn, "conversation"); err != nil {
|
|
t.Fatalf("Rewind: %v", err)
|
|
}
|
|
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
|
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
|
}
|
|
sys := systemPromptFrom(tabB.Ctrl.History())
|
|
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
|
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
|
}
|
|
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
|
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
|
}
|
|
}
|
|
|
|
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
|
t.Helper()
|
|
if tab == nil || tab.Ctrl == nil {
|
|
t.Fatal("tab controller is required")
|
|
}
|
|
sys := systemPromptFrom(tab.Ctrl.History())
|
|
if strings.TrimSpace(sys) == "" {
|
|
t.Fatal("tab controller did not expose a system prompt")
|
|
}
|
|
sessionDir := tab.Ctrl.SessionDir()
|
|
sessionPath := tab.Ctrl.SessionPath()
|
|
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
|
label := tab.Ctrl.Label()
|
|
tab.Ctrl.Close()
|
|
|
|
sess := agent.NewSession(sys)
|
|
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{
|
|
Runner: ag,
|
|
Executor: ag,
|
|
SessionDir: sessionDir,
|
|
SessionPath: sessionPath,
|
|
WorkspaceRoot: workspaceRoot,
|
|
Label: label,
|
|
SystemPrompt: sys,
|
|
Sink: event.Discard,
|
|
})
|
|
tab.Ctrl = ctrl
|
|
app.bindControllerDisplayRecorder(ctrl)
|
|
return ctrl
|
|
}
|
|
|
|
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
|
t.Helper()
|
|
ctrl.SubmitUserTurn(input, input)
|
|
waitNotRunning(t, ctrl)
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
checkpoints := ctrl.Checkpoints()
|
|
if len(checkpoints) > 0 {
|
|
return checkpoints[len(checkpoints)-1].Turn
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("controller did not record a checkpoint")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
|
t.Fatalf("set stale auto title: %v", err)
|
|
}
|
|
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
meta, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if got := meta.TopicTitle; got != defaultTopicTitle {
|
|
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
|
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
|
t.Fatalf("title source = %q, want auto", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
meta, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if got := meta.TopicTitle; got != "Manual title" {
|
|
t.Fatalf("reused manual topic title = %q, want Manual title", got)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
|
|
t.Fatalf("stored title = %q, want Manual title", got)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
|
t.Fatalf("set stale auto title: %v", err)
|
|
}
|
|
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
|
|
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
|
|
app.tabs[activeTab.ID] = activeTab
|
|
app.tabs[reusableTab.ID] = reusableTab
|
|
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
|
|
app.activeTabID = activeTab.ID
|
|
|
|
titlePath := topicTitlesPath(projectRoot)
|
|
if err := os.Remove(titlePath); err != nil {
|
|
t.Fatalf("remove title file: %v", err)
|
|
}
|
|
if err := os.Mkdir(titlePath, 0o755); err != nil {
|
|
t.Fatalf("replace title file with directory: %v", err)
|
|
}
|
|
|
|
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
|
|
t.Fatal("EnsureBlankTab succeeded, want title reset error")
|
|
}
|
|
if got := app.activeTabID; got != activeTab.ID {
|
|
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
|
|
}
|
|
}
|
|
|
|
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
|
// instead of creating a fresh topic, for global scope.
|
|
|
|
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("global", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
meta, err := app.EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta.TopicID != topic.ID {
|
|
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
|
}
|
|
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
|
|
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
|
|
}
|
|
}
|
|
|
|
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
|
// instead of creating a fresh topic, for project scope.
|
|
|
|
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
meta, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta.TopicID != topic.ID {
|
|
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
|
}
|
|
var topics []string
|
|
for _, project := range loadProjectsFile().Projects {
|
|
if project.Root == projectRoot {
|
|
topics = project.Topics
|
|
break
|
|
}
|
|
}
|
|
if len(topics) != 1 {
|
|
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
|
|
}
|
|
}
|
|
|
|
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := robustTempDir(t)
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("CreateTopic: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
|
|
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
|
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
|
|
}
|
|
|
|
meta, err := app.EnsureBlankTab("project", projectRoot)
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if meta.TopicID == topic.ID {
|
|
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
|
|
}
|
|
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
|
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
|
|
}
|
|
}
|
|
|
|
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
|
|
// into ensureTopicIndexed, whose intentional prepend clears the delete
|
|
// tombstone and resurrects the topic the user removed.
|
|
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
// Race product on disk: deleted topic whose default title lingered in the
|
|
// global title map (title-only, absent from GlobalTopics, no sessions).
|
|
tombstonedID := "topic_tombstone_blank"
|
|
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
|
|
t.Fatalf("set lingering title: %v", err)
|
|
}
|
|
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
|
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
|
return true, nil
|
|
}); err != nil {
|
|
t.Fatalf("seed tombstone: %v", err)
|
|
}
|
|
|
|
meta, err := NewApp().EnsureBlankTab("global", "")
|
|
if err != nil {
|
|
t.Fatalf("EnsureBlankTab: %v", err)
|
|
}
|
|
if meta.TopicID == tombstonedID {
|
|
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
|
|
}
|
|
f := loadProjectsFile()
|
|
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
|
|
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
|
|
}
|
|
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
|
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
|
|
}
|
|
}
|
|
|
|
// NewSession skips the snapshot when the current tab has no real conversation content.
|
|
|
|
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := t.TempDir()
|
|
path := agent.NewSessionPath(dir, "model-a")
|
|
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
|
|
app := NewApp()
|
|
app.setTestCtrl(ctrl, "model-a")
|
|
|
|
if err := app.NewSession(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := ctrl.SessionPath(); got != path {
|
|
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
|
|
}
|
|
}
|
|
|
|
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
oldTopicID := "topic_old"
|
|
oldTopicTitle := "Old topic"
|
|
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
|
|
sess := &agent.Session{}
|
|
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
|
|
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
|
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
|
|
|
|
app := NewApp()
|
|
app.setTestCtrl(ctrl, "model-a")
|
|
tab := app.tabs["test"]
|
|
tab.Scope = "project"
|
|
tab.WorkspaceRoot = projectRoot
|
|
tab.TopicID = oldTopicID
|
|
tab.TopicTitle = oldTopicTitle
|
|
tab.SessionPath = oldPath
|
|
app.projectTreeChangedHook = func() {}
|
|
|
|
if err := app.NewSession(); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
if got := tab.TopicID; got == "" || got == oldTopicID {
|
|
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
|
|
}
|
|
if got := tab.TopicTitle; got != defaultTopicTitle {
|
|
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
newPath := ctrl.SessionPath()
|
|
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
|
|
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
|
|
}
|
|
|
|
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write new session: %v", err)
|
|
}
|
|
if !app.maybeAutoTitleTopic(tab) {
|
|
t.Fatalf("new session should auto-title its fresh topic")
|
|
}
|
|
|
|
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
|
|
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
|
|
}
|
|
newMeta, ok, err := agent.LoadBranchMeta(newPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
|
|
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
|
|
}
|
|
}
|
|
|
|
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := agent.NewSessionPath(dir, "model-a")
|
|
ctrl := controllerWithContent(t, path)
|
|
app := NewApp()
|
|
app.projectTreeChangedHook = func() {}
|
|
app.setTestCtrl(ctrl, "model-a")
|
|
tab := app.tabs["test"]
|
|
tab.TopicID = "topic_old"
|
|
tab.TopicTitle = "Old topic"
|
|
|
|
// Block desktopConfigDir-backed topic-index writes without affecting the
|
|
// session directory, which exercises the post-NewSession repair failure path.
|
|
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
|
|
t.Fatalf("mkdir desktop config parent: %v", err)
|
|
}
|
|
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
|
|
t.Fatalf("block desktop config dir: %v", err)
|
|
}
|
|
|
|
if err := app.NewSession(); err != nil {
|
|
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
|
|
}
|
|
if got := tab.TopicID; got == "" || got == "topic_old" {
|
|
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
|
|
}
|
|
if got := tab.TopicTitle; got != defaultTopicTitle {
|
|
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
|
|
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
|
|
}
|
|
}
|