3399 lines
116 KiB
Go
3399 lines
116 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
)
|
|
|
|
func waitForTabReady(t *testing.T, app *App, tabID string) *WorkspaceTab {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
app.mu.RLock()
|
|
tab := app.tabs[tabID]
|
|
ready := tab != nil && tab.Ready
|
|
startupErr := ""
|
|
if tab != nil {
|
|
startupErr = tab.StartupErr
|
|
}
|
|
app.mu.RUnlock()
|
|
if tab == nil {
|
|
t.Fatalf("tab %q was not found", tabID)
|
|
}
|
|
if ready {
|
|
if startupErr != "" {
|
|
t.Fatalf("tab %q startup error: %s", tabID, startupErr)
|
|
}
|
|
if tab.Ctrl != nil {
|
|
t.Cleanup(func() { tab.Ctrl.Close() })
|
|
}
|
|
return tab
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("tab %q was not ready before timeout", tabID)
|
|
return nil
|
|
}
|
|
|
|
func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
UpdatedAt: time.Now(),
|
|
Scope: "project",
|
|
WorkspaceRoot: workspaceRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: topicTitle,
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeTopicSessionWithPrompt(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot, prompt string, updatedAt time.Time) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
scope := "global"
|
|
if strings.TrimSpace(workspaceRoot) != "" {
|
|
scope = "project"
|
|
}
|
|
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
|
|
CreatedAt: updatedAt.Add(-time.Minute),
|
|
UpdatedAt: updatedAt,
|
|
Scope: scope,
|
|
WorkspaceRoot: workspaceRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: topicTitle,
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeLegacySession(t *testing.T, dir, name, prompt string, modTime time.Time) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write legacy session: %v", err)
|
|
}
|
|
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
|
t.Fatalf("chtimes legacy session: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modTime time.Time) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir legacy sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, name)
|
|
body := `{"type":"user.message","id":1,"ts":"t","turn":0,"text":` + strconv.Quote(prompt) + `}` + "\n" +
|
|
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":` + strconv.Quote(reply) + `,"toolCalls":[],"usage":{},"costUsd":0}` + "\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write legacy event session: %v", err)
|
|
}
|
|
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
|
t.Fatalf("chtimes legacy event session: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestSessionListCacheRefillsAfterInvalidate(t *testing.T) {
|
|
cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
|
|
dir := t.TempDir()
|
|
first := []agent.SessionInfo{{Path: filepath.Join(dir, "first.jsonl")}}
|
|
second := []agent.SessionInfo{{Path: filepath.Join(dir, "second.jsonl")}}
|
|
|
|
token := cache.versionToken()
|
|
cache.put(dir, first, map[string]string{"first.jsonl": "First"}, token)
|
|
if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "first.jsonl" || titles["first.jsonl"] != "First" {
|
|
t.Fatalf("initial cache entry = %+v, %+v, %v", infos, titles, ok)
|
|
}
|
|
|
|
cache.invalidate()
|
|
if _, _, ok := cache.get(dir); ok {
|
|
t.Fatalf("cache entry survived invalidate")
|
|
}
|
|
cache.put(dir, first, map[string]string{"first.jsonl": "stale"}, token)
|
|
if _, _, ok := cache.get(dir); ok {
|
|
t.Fatalf("stale token repopulated cache after invalidate")
|
|
}
|
|
|
|
token = cache.versionToken()
|
|
cache.put(dir, second, map[string]string{"second.jsonl": "Second"}, token)
|
|
if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "second.jsonl" || titles["second.jsonl"] != "Second" {
|
|
t.Fatalf("refilled cache entry = %+v, %+v, %v", infos, titles, ok)
|
|
}
|
|
}
|
|
|
|
func TestRenameSessionInvalidatesProjectTreeCache(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
oldProjectCache := projectSessionCache
|
|
projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}}
|
|
t.Cleanup(func() {
|
|
projectSessionCache = oldProjectCache
|
|
})
|
|
|
|
dir := t.TempDir()
|
|
sessionPath := filepath.Join(dir, "rename-me.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test"})
|
|
defer ctrl.Close()
|
|
app := NewApp()
|
|
app.setTestCtrl(ctrl, "")
|
|
|
|
token := projectSessionCache.versionToken()
|
|
projectSessionCache.put(dir, []agent.SessionInfo{{Path: sessionPath}}, map[string]string{"rename-me.jsonl": "old"}, token)
|
|
if _, _, ok := projectSessionCache.get(dir); !ok {
|
|
t.Fatalf("expected primed project tree cache")
|
|
}
|
|
if err := app.RenameSession(sessionPath, "new title"); err != nil {
|
|
t.Fatalf("RenameSession: %v", err)
|
|
}
|
|
if _, _, ok := projectSessionCache.get(dir); ok {
|
|
t.Fatalf("RenameSession should invalidate project tree cache")
|
|
}
|
|
}
|
|
|
|
func TestTopicMetadataUpdatesPreserveExistingEntriesWhenTimedReadSlotsFull(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := saveTopicTitles(projectRoot, map[string]string{"old": "Old"}); err != nil {
|
|
t.Fatalf("save old title: %v", err)
|
|
}
|
|
if err := saveTopicTitleSources(projectRoot, map[string]string{"old": topicTitleSourceManual}); err != nil {
|
|
t.Fatalf("save old source: %v", err)
|
|
}
|
|
if err := saveTopicCreatedAts(projectRoot, map[string]int64{"old": 100}); err != nil {
|
|
t.Fatalf("save old created-at: %v", err)
|
|
}
|
|
|
|
release := occupyReadFileWithTimeoutSlots(t)
|
|
if err := setTopicTitleWithSource(projectRoot, "new", "New", topicTitleSourceAuto); err != nil {
|
|
t.Fatalf("setTopicTitleWithSource: %v", err)
|
|
}
|
|
if err := setTopicCreatedAt(projectRoot, "new", 200); err != nil {
|
|
t.Fatalf("setTopicCreatedAt: %v", err)
|
|
}
|
|
release()
|
|
|
|
titles := loadTopicTitles(projectRoot)
|
|
if got := titles["old"]; got != "Old" {
|
|
t.Fatalf("old title = %q, want Old (all titles: %v)", got, titles)
|
|
}
|
|
if got := titles["new"]; got != "New" {
|
|
t.Fatalf("new title = %q, want New (all titles: %v)", got, titles)
|
|
}
|
|
sources := loadTopicTitleSources(projectRoot)
|
|
if got := sources["old"]; got != topicTitleSourceManual {
|
|
t.Fatalf("old source = %q, want %q (all sources: %v)", got, topicTitleSourceManual, sources)
|
|
}
|
|
if got := sources["new"]; got != topicTitleSourceAuto {
|
|
t.Fatalf("new source = %q, want %q (all sources: %v)", got, topicTitleSourceAuto, sources)
|
|
}
|
|
created := loadTopicCreatedAts(projectRoot)
|
|
if got := created["old"]; got != 100 {
|
|
t.Fatalf("old created-at = %d, want 100 (all created: %v)", got, created)
|
|
}
|
|
if got := created["new"]; got != 200 {
|
|
t.Fatalf("new created-at = %d, want 200 (all created: %v)", got, created)
|
|
}
|
|
}
|
|
|
|
func TestDeleteTopicKeepsSessionHistory(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_keep_history"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)
|
|
|
|
if err := NewApp().DeleteTopic(topicID); err != nil {
|
|
t.Fatalf("delete topic: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); err != nil {
|
|
t.Fatalf("delete topic should keep session history: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
|
t.Fatalf("topic title should be removed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSetTopicPinnedOrdersProjectTopics(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, "topic_a", "Alpha"); err != nil {
|
|
t.Fatalf("set topic a title: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, "topic_b", "Beta"); err != nil {
|
|
t.Fatalf("set topic b title: %v", err)
|
|
}
|
|
app := NewApp()
|
|
nodes := app.ListProjectTree()
|
|
if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_a" || got[1] != "topic_b" {
|
|
t.Fatalf("initial topic order = %v, want [topic_a topic_b]", got)
|
|
}
|
|
|
|
if err := app.SetTopicPinned("topic_b", true); err != nil {
|
|
t.Fatalf("pin topic: %v", err)
|
|
}
|
|
nodes = app.ListProjectTree()
|
|
if got := []string{nodes[0].Children[0].TopicID, nodes[0].Children[1].TopicID}; got[0] != "topic_b" || got[1] != "topic_a" {
|
|
t.Fatalf("pinned topic order = %v, want [topic_b topic_a]", got)
|
|
}
|
|
if !nodes[0].Children[0].Pinned {
|
|
t.Fatalf("pinned topic should expose pinned=true")
|
|
}
|
|
|
|
if err := app.SetTopicPinned("topic_b", false); err != nil {
|
|
t.Fatalf("unpin topic: %v", err)
|
|
}
|
|
nodes = app.ListProjectTree()
|
|
if nodes[0].Children[0].Pinned || nodes[0].Children[1].Pinned {
|
|
t.Fatalf("unpin should clear pinned flags: %#v", nodes[0].Children)
|
|
}
|
|
}
|
|
|
|
func TestSetProjectPinnedOrdersProjectFolders(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
third := t.TempDir()
|
|
if err := addProject(first, "First"); err != nil {
|
|
t.Fatalf("add first project: %v", err)
|
|
}
|
|
if err := addProject(second, "Second"); err != nil {
|
|
t.Fatalf("add second project: %v", err)
|
|
}
|
|
if err := addProject(third, "Third"); err != nil {
|
|
t.Fatalf("add third project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
if err := app.ReorderProjects([]string{third, first, second}); err != nil {
|
|
t.Fatalf("ReorderProjects: %v", err)
|
|
}
|
|
if err := app.SetProjectPinned(second, true); err != nil {
|
|
t.Fatalf("pin project: %v", err)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != second || got[1] != third || got[2] != first {
|
|
t.Fatalf("pinned project order = %v, want %v", got, []string{second, third, first})
|
|
}
|
|
if !nodes[0].Pinned {
|
|
t.Fatalf("pinned project should expose pinned=true")
|
|
}
|
|
|
|
if err := app.SetProjectPinned(second, false); err != nil {
|
|
t.Fatalf("unpin project: %v", err)
|
|
}
|
|
nodes = app.ListProjectTree()
|
|
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
|
|
t.Fatalf("unpinned project order = %v, want %v", got, []string{third, first, second})
|
|
}
|
|
if nodes[0].Pinned || nodes[1].Pinned || nodes[2].Pinned {
|
|
t.Fatalf("unpin should clear pinned flags: %#v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestDeleteTopicClearsPinnedTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, "topic_pinned_delete", "Pinned"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
app := NewApp()
|
|
if err := app.SetTopicPinned("topic_pinned_delete", true); err != nil {
|
|
t.Fatalf("pin topic: %v", err)
|
|
}
|
|
if err := app.DeleteTopic("topic_pinned_delete"); err != nil {
|
|
t.Fatalf("delete topic: %v", err)
|
|
}
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 1 {
|
|
t.Fatalf("projects len = %d, want 1", len(projects))
|
|
}
|
|
if got := projects[0].PinnedTopics; len(got) != 0 {
|
|
t.Fatalf("pinned topics after delete = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestRenameProjectUpdatesSidebarTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
|
|
t.Fatalf("rename project: %v", err)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("project tree len = %d, want 1", len(nodes))
|
|
}
|
|
if got := nodes[0].Label; got != "Client API" {
|
|
t.Fatalf("project label = %q, want Client API", got)
|
|
}
|
|
|
|
if err := NewApp().RenameProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("clear project title: %v", err)
|
|
}
|
|
nodes = NewApp().ListProjectTree()
|
|
if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
|
|
t.Fatalf("cleared project label = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestListWorkspacesUsesProjectRegistryTitles(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Client API"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
workspaces := NewApp().ListWorkspaces()
|
|
if len(workspaces) != 1 {
|
|
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
|
|
}
|
|
if got := workspaces[0].Path; got != projectRoot {
|
|
t.Fatalf("workspace path = %q, want %q", got, projectRoot)
|
|
}
|
|
if got := workspaces[0].Name; got != "Client API" {
|
|
t.Fatalf("workspace name = %q, want Client API", got)
|
|
}
|
|
}
|
|
|
|
func TestListWorkspacesMigratesLegacyWorkspaceList(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
legacyRoot := t.TempDir()
|
|
rememberWorkspace(legacyRoot)
|
|
|
|
workspaces := NewApp().ListWorkspaces()
|
|
if len(workspaces) != 1 {
|
|
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
|
|
}
|
|
if got := workspaces[0].Path; got != legacyRoot {
|
|
t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
|
|
}
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 1 || projects[0].Root != legacyRoot {
|
|
t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
|
|
}
|
|
}
|
|
|
|
func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour))
|
|
newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour))
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
|
t.Fatalf("project tree = %#v, want global folder", nodes)
|
|
}
|
|
if got := len(nodes[0].Children); got != 2 {
|
|
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
|
|
}
|
|
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(newer); got != want {
|
|
t.Fatalf("newest topic first = %q, want %q", got, want)
|
|
}
|
|
if got, want := nodes[0].Children[1].TopicID, legacySessionTopicID(older); got != want {
|
|
t.Fatalf("older topic second = %q, want %q", got, want)
|
|
}
|
|
|
|
meta, ok, err := agent.LoadBranchMeta(newer)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load migrated meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.Scope != "global" || meta.WorkspaceRoot != "" || meta.TopicID != legacySessionTopicID(newer) {
|
|
t.Fatalf("migrated meta = %+v", meta)
|
|
}
|
|
|
|
nodes = NewApp().ListProjectTree()
|
|
if got := len(nodes[0].Children); got != 2 {
|
|
t.Fatalf("migration should be idempotent, global topics = %d", got)
|
|
}
|
|
}
|
|
|
|
func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
|
|
recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
|
// Simulate an upgrade from the filename-only classifier: the v1 marker
|
|
// must not prevent the new conservative pass from recovering this history.
|
|
if err := os.WriteFile(filepath.Join(dir, ".topics-migrated"), nil, 0o644); err != nil {
|
|
t.Fatalf("write v1 migration marker: %v", err)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
|
t.Fatalf("project tree = %#v, want global folder", nodes)
|
|
}
|
|
if got := len(nodes[0].Children); got != 2 {
|
|
t.Fatalf("global migrated topics = %d, want both sessions preserved: %#v", got, nodes[0].Children)
|
|
}
|
|
wantTopics := map[string]bool{legacySessionTopicID(normal): true, legacySessionTopicID(recovery): true}
|
|
for _, node := range nodes[0].Children {
|
|
delete(wantTopics, node.TopicID)
|
|
}
|
|
if len(wantTopics) != 0 {
|
|
t.Fatalf("migrated topics missing %v: %#v", wantTopics, nodes[0].Children)
|
|
}
|
|
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
|
t.Fatalf("load recovery meta: %v", err)
|
|
} else if strings.TrimSpace(meta.TopicID) == "" {
|
|
t.Fatal("ambiguous legacy recovery branch was not migrated into a visible topic")
|
|
}
|
|
}
|
|
|
|
func TestUnmodifiedRecoveryCopyDoesNotMigrateIntoTopics(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal")
|
|
coverDesktopRecoveryParent(t, parent, branchMsgs)
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project tree = %#v, want only the covering parent topic", nodes)
|
|
}
|
|
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
|
t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
|
|
} else if strings.TrimSpace(meta.TopicID) != "" {
|
|
t.Fatalf("parent-covered recovery copy was migrated into topic %q", meta.TopicID)
|
|
}
|
|
}
|
|
|
|
func TestCoveredRecoveryCopyBecomesVisibleAfterMigratedParentDeletion(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "parent-delete")
|
|
coverDesktopRecoveryParent(t, parent, branchMsgs)
|
|
app := NewApp()
|
|
|
|
app.ListProjectTree()
|
|
for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
|
|
if _, err := os.Stat(filepath.Join(dir, marker)); err != nil {
|
|
t.Fatalf("expected %s after migration: %v", marker, err)
|
|
}
|
|
}
|
|
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
|
|
t.Fatalf("load skipped recovery meta: ok=%v err=%v", ok, err)
|
|
} else if strings.TrimSpace(meta.TopicID) != "" {
|
|
t.Fatalf("covered recovery copy was migrated before parent deletion: %+v", meta)
|
|
}
|
|
|
|
if err := app.DeleteSession(parent); err != nil {
|
|
t.Fatalf("DeleteSession parent: %v", err)
|
|
}
|
|
for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
|
|
if _, err := os.Stat(filepath.Join(dir, marker)); !os.IsNotExist(err) {
|
|
t.Fatalf("%s survived live session deletion: %v", marker, err)
|
|
}
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
meta, ok, err := agent.LoadBranchMeta(recovery)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load recovery meta after parent deletion: ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.TopicID != legacySessionTopicID(recovery) {
|
|
t.Fatalf("recovery topic after parent deletion = %q, want %q", meta.TopicID, legacySessionTopicID(recovery))
|
|
}
|
|
for _, root := range nodes {
|
|
for _, node := range root.Children {
|
|
if node.TopicID == meta.TopicID {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
t.Fatalf("project tree after parent deletion = %#v, want recovery topic %q", nodes, meta.TopicID)
|
|
}
|
|
|
|
func TestHistoryMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
|
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
|
|
defer ctrl.Close()
|
|
app := NewApp()
|
|
app.setTestCtrl(ctrl, "")
|
|
|
|
sessions := app.ListSessions()
|
|
for _, session := range sessions {
|
|
if filepath.Clean(session.Path) != filepath.Clean(recovery) {
|
|
continue
|
|
}
|
|
if !session.Recovered {
|
|
t.Fatalf("history session recovered flag = false, want true: %+v", session)
|
|
}
|
|
if session.RecoveryCopy {
|
|
t.Fatalf("legacy filename-only recovery was marked safe for bulk cleanup: %+v", session)
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
|
|
}
|
|
|
|
func TestTrashMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
|
if err := deleteSessionFile(dir, recovery); err != nil {
|
|
t.Fatalf("delete recovery session: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))
|
|
|
|
sessions := NewApp().ListTrashedSessions()
|
|
if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
|
|
t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
|
|
}
|
|
if !sessions[0].Recovered {
|
|
t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
|
|
}
|
|
if sessions[0].RecoveryCopy {
|
|
t.Fatalf("legacy filename-only recovery was marked safe for bulk purge: %+v", sessions[0])
|
|
}
|
|
}
|
|
|
|
func TestProjectTreeKeepsAmbiguousMigratedRecoveryTopicVisible(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
|
topicID := legacySessionTopicID(recovery)
|
|
if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
|
|
ID: agent.BranchID(recovery),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
UpdatedAt: time.Now().Add(-time.Hour),
|
|
Scope: "global",
|
|
TopicID: topicID,
|
|
TopicTitle: "恢复分支",
|
|
Turns: 1,
|
|
Preview: "legacy recovery prompt",
|
|
}); err != nil {
|
|
t.Fatalf("save migrated recovery meta: %v", err)
|
|
}
|
|
// A v1 repair pass skipped this recovery-named record. The v2 pass must
|
|
// revisit it and restore its existing topic to the sidebar index.
|
|
if err := os.WriteFile(filepath.Join(dir, ".topic-indexes-repaired"), nil, 0o644); err != nil {
|
|
t.Fatalf("write v1 repair marker: %v", err)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) == 0 {
|
|
t.Fatal("project tree is empty")
|
|
}
|
|
for _, node := range nodes[0].Children {
|
|
if node.TopicID == topicID {
|
|
if node.Turns != 1 {
|
|
t.Fatalf("recovery topic turns = %d, want 1", node.Turns)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("ambiguous recovery topic should stay visible: %#v", nodes)
|
|
}
|
|
|
|
func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
writeLegacySession(t, dir, "first.jsonl", "first legacy prompt", time.Now().Add(-time.Hour))
|
|
|
|
// First render migrates the legacy session and, with nothing deferred, stamps
|
|
// the one-shot marker so later renders can skip the scan.
|
|
NewApp().ListProjectTree()
|
|
if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
|
|
t.Fatalf("expected migration marker after a complete pass: %v", err)
|
|
}
|
|
|
|
// A CLI-created session added after the marker invalidates the lightweight
|
|
// gate and gets a fresh migration pass.
|
|
time.Sleep(10 * time.Millisecond)
|
|
second := writeLegacySession(t, dir, "second.jsonl", "second legacy prompt", time.Now())
|
|
NewApp().ListProjectTree()
|
|
meta, ok, err := agent.LoadBranchMeta(second)
|
|
if err != nil {
|
|
t.Fatalf("load second meta: %v", err)
|
|
}
|
|
if !ok || strings.TrimSpace(meta.TopicID) != legacySessionTopicID(second) {
|
|
t.Fatalf("new session after marker should be migrated, got ok=%v meta=%+v", ok, meta)
|
|
}
|
|
}
|
|
|
|
func TestProjectTreeRepairsIndexedGlobalTopicsAfterMigrationMarker(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
sessionPath := writeLegacySession(t, dir, "desktop-legacy.jsonl", "who are you", time.Now().Add(-time.Hour))
|
|
topicID := "legacy_desktop-legacy_1234"
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
|
ID: agent.BranchID(sessionPath),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
UpdatedAt: time.Now().Add(-time.Hour),
|
|
Scope: "global",
|
|
TopicID: topicID,
|
|
TopicTitle: "你是谁",
|
|
Turns: 1,
|
|
Preview: "who are you",
|
|
}); err != nil {
|
|
t.Fatalf("save meta: %v", err)
|
|
}
|
|
markTopicMigrationDone(dir)
|
|
|
|
repaired := migrateLegacySessionsIntoGlobalTopics(dir)
|
|
if len(repaired) != 1 || repaired[0] != topicID {
|
|
t.Fatalf("repaired topics = %#v, want %q", repaired, topicID)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
var global *ProjectNode
|
|
for i := range nodes {
|
|
if nodes[i].Kind == "global_folder" {
|
|
global = &nodes[i]
|
|
break
|
|
}
|
|
}
|
|
if global == nil {
|
|
t.Fatalf("project tree = %#v, want repaired Global folder", nodes)
|
|
}
|
|
if len(global.Children) != 1 || global.Children[0].TopicID != topicID || global.Children[0].Label != "你是谁" {
|
|
t.Fatalf("global children = %#v, want repaired topic %q with preserved title", global.Children, topicID)
|
|
}
|
|
f := loadProjectsFile()
|
|
if !containsDesktopString(f.GlobalTopics, topicID) {
|
|
t.Fatalf("globalTopics = %#v, want %q", f.GlobalTopics, topicID)
|
|
}
|
|
if got := loadTopicTitle("", topicID); got != "你是谁" {
|
|
t.Fatalf("global topic title = %q, want 你是谁", got)
|
|
}
|
|
}
|
|
|
|
func TestDeletedRepairedGlobalTopicIsNotAutoRestored(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
sessionPath := writeLegacySession(t, dir, "desktop-delete.jsonl", "delete repaired topic", time.Now().Add(-time.Hour))
|
|
topicID := "legacy_desktop-delete_1234"
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
|
ID: agent.BranchID(sessionPath),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
UpdatedAt: time.Now().Add(-time.Hour),
|
|
Scope: "global",
|
|
TopicID: topicID,
|
|
TopicTitle: "临时 Global",
|
|
Turns: 1,
|
|
Preview: "delete repaired topic",
|
|
}); err != nil {
|
|
t.Fatalf("save meta: %v", err)
|
|
}
|
|
markTopicMigrationDone(dir)
|
|
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != topicID {
|
|
t.Fatalf("initial repaired topics = %#v, want %q", repaired, topicID)
|
|
}
|
|
if err := NewApp().DeleteTopic(topicID); err != nil {
|
|
t.Fatalf("delete repaired topic: %v", err)
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
later := time.Now()
|
|
if err := os.Chtimes(sessionPath, later, later); err != nil {
|
|
t.Fatalf("touch session after delete: %v", err)
|
|
}
|
|
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
|
t.Fatalf("deleted repaired topic was restored: %#v", repaired)
|
|
}
|
|
f := loadProjectsFile()
|
|
if containsDesktopString(f.GlobalTopics, topicID) {
|
|
t.Fatalf("globalTopics = %#v, deleted topic %q should stay removed", f.GlobalTopics, topicID)
|
|
}
|
|
if got := loadTopicTitle("", topicID); got != "" {
|
|
t.Fatalf("global topic title = %q, want deleted", got)
|
|
}
|
|
if !containsDesktopString(f.DeletedTopics, topicID) {
|
|
t.Fatalf("deletedTopics = %#v, want tombstone for %q", f.DeletedTopics, topicID)
|
|
}
|
|
}
|
|
|
|
func TestRepairRescanKeepsIndexedTopicsUntouched(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
writeIndexedSession := func(name, topicID, title string, at time.Time) string {
|
|
p := writeLegacySession(t, dir, name, "prompt "+title, at)
|
|
if err := agent.SaveBranchMetaPreserveUpdated(p, agent.BranchMeta{
|
|
ID: agent.BranchID(p),
|
|
CreatedAt: at.Add(-time.Hour),
|
|
UpdatedAt: at,
|
|
Scope: "global",
|
|
TopicID: topicID,
|
|
TopicTitle: title,
|
|
Turns: 1,
|
|
Preview: "prompt " + title,
|
|
}); err != nil {
|
|
t.Fatalf("save meta %s: %v", name, err)
|
|
}
|
|
if err := os.Chtimes(p, at, at); err != nil {
|
|
t.Fatalf("chtimes %s: %v", name, err)
|
|
}
|
|
return p
|
|
}
|
|
now := time.Now()
|
|
olderPath := writeIndexedSession("older.jsonl", "legacy_older_000000000001", "旧话题", now.Add(-3*time.Hour))
|
|
writeIndexedSession("newer.jsonl", "legacy_newer_000000000002", "新话题", now.Add(-time.Hour))
|
|
markTopicMigrationDone(dir)
|
|
|
|
// First pass repairs both missing topics.
|
|
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 2 {
|
|
t.Fatalf("initial repaired topics = %#v, want 2 entries", repaired)
|
|
}
|
|
before := loadProjectsFile()
|
|
projPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
|
|
statBefore, err := os.Stat(projPath)
|
|
if err != nil {
|
|
t.Fatalf("stat projects file: %v", err)
|
|
}
|
|
|
|
// Ordinary session activity invalidates the repair marker; the rescan must
|
|
// not reorder the indexed topics, rewrite the projects file, or report the
|
|
// already-visible topics as repaired (the callers bind blank Global tabs
|
|
// to repaired[0]).
|
|
time.Sleep(10 * time.Millisecond)
|
|
later := time.Now()
|
|
if err := os.Chtimes(olderPath, later, later); err != nil {
|
|
t.Fatalf("touch session: %v", err)
|
|
}
|
|
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 0 {
|
|
t.Fatalf("steady-state rescan reported repairs: %#v", repaired)
|
|
}
|
|
after := loadProjectsFile()
|
|
if !sameStringList(before.GlobalTopics, after.GlobalTopics) {
|
|
t.Fatalf("rescan reordered globalTopics: before=%v after=%v", before.GlobalTopics, after.GlobalTopics)
|
|
}
|
|
statAfter, err := os.Stat(projPath)
|
|
if err != nil {
|
|
t.Fatalf("stat projects file: %v", err)
|
|
}
|
|
if !statAfter.ModTime().Equal(statBefore.ModTime()) {
|
|
t.Fatalf("steady-state rescan rewrote the projects file")
|
|
}
|
|
}
|
|
|
|
func TestBatchPrependRespectsTombstoneWrittenAfterScanSnapshot(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
topicID := "legacy_race_000000000001"
|
|
// Simulate a DeleteTopic landing between a repair scan's DeletedTopics
|
|
// snapshot and its batch write: the tombstone exists by the time the
|
|
// prepend runs, so the batch must drop the topic instead of resurrecting it.
|
|
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
|
f.DeletedTopics = prependUniqueString(f.DeletedTopics, topicID)
|
|
return true, nil
|
|
}); err != nil {
|
|
t.Fatalf("seed tombstone: %v", err)
|
|
}
|
|
if err := prependTopicsInProjectsFile("", []string{topicID, "legacy_live_000000000002"}, false); err != nil {
|
|
t.Fatalf("batch prepend: %v", err)
|
|
}
|
|
f := loadProjectsFile()
|
|
if containsDesktopString(f.GlobalTopics, topicID) {
|
|
t.Fatalf("globalTopics = %#v, tombstoned topic %q must not be batch-prepended", f.GlobalTopics, topicID)
|
|
}
|
|
if !containsDesktopString(f.GlobalTopics, "legacy_live_000000000002") {
|
|
t.Fatalf("globalTopics = %#v, live topic should still be prepended", f.GlobalTopics)
|
|
}
|
|
if !containsDesktopString(f.DeletedTopics, topicID) {
|
|
t.Fatalf("deletedTopics = %#v, tombstone should survive a batch prepend", f.DeletedTopics)
|
|
}
|
|
|
|
// Intentional single-topic writes (create/restore/tab indexing) clear the
|
|
// tombstone and bring the topic back in the same projects-file transaction.
|
|
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
|
t.Fatalf("single prepend: %v", err)
|
|
}
|
|
f = loadProjectsFile()
|
|
if !containsDesktopString(f.GlobalTopics, topicID) {
|
|
t.Fatalf("globalTopics = %#v, single prepend should restore %q", f.GlobalTopics, topicID)
|
|
}
|
|
if containsDesktopString(f.DeletedTopics, topicID) {
|
|
t.Fatalf("deletedTopics = %#v, single prepend should clear the tombstone", f.DeletedTopics)
|
|
}
|
|
}
|
|
|
|
func TestTombstonedTitleOnlyTopicStaysHiddenInProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
// Control: a legitimate title-only topic (not in GlobalTopics) must keep
|
|
// rendering through the orderedTopicIDs title-map fallback.
|
|
controlID := "topic_control_visible"
|
|
if err := setTopicTitle("", controlID, "正常话题"); err != nil {
|
|
t.Fatalf("set control title: %v", err)
|
|
}
|
|
// Race product: DeleteTopic landed, but a stale whole-map save wrote the
|
|
// topic's title back — tombstoned, absent from GlobalTopics, title present.
|
|
tombstonedID := "legacy_raced_000000000009"
|
|
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
|
t.Fatalf("set stale 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)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
var global *ProjectNode
|
|
for i := range nodes {
|
|
if nodes[i].Kind == "global_folder" {
|
|
global = &nodes[i]
|
|
break
|
|
}
|
|
}
|
|
if global == nil {
|
|
t.Fatalf("project tree = %#v, want Global folder", nodes)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, c := range global.Children {
|
|
seen[c.TopicID] = true
|
|
}
|
|
if seen[tombstonedID] {
|
|
t.Fatalf("global children = %#v, tombstoned title-only topic %q must stay hidden", global.Children, tombstonedID)
|
|
}
|
|
if !seen[controlID] {
|
|
t.Fatalf("global children = %#v, legitimate title-only topic %q should still render", global.Children, controlID)
|
|
}
|
|
}
|
|
|
|
func TestRepairPassPrunesStaleTitleOfDeletedTopic(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
if err := addProject(t.TempDir(), "Existing project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
// Stale race product on disk: tombstoned topic whose title lingers in the
|
|
// global title map. The next repair pass that saves the whole map must
|
|
// prune it instead of persisting it again.
|
|
tombstonedID := "legacy_raced_000000000010"
|
|
if err := setTopicTitle("", tombstonedID, "被删除的话题"); err != nil {
|
|
t.Fatalf("set stale 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)
|
|
}
|
|
|
|
sessionPath := writeLegacySession(t, dir, "desktop-prune.jsonl", "needs repair", time.Now().Add(-time.Hour))
|
|
repairID := "legacy_desktop-prune_1234"
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
|
ID: agent.BranchID(sessionPath),
|
|
CreatedAt: time.Now().Add(-2 * time.Hour),
|
|
UpdatedAt: time.Now().Add(-time.Hour),
|
|
Scope: "global",
|
|
TopicID: repairID,
|
|
TopicTitle: "待修复话题",
|
|
Turns: 1,
|
|
Preview: "needs repair",
|
|
}); err != nil {
|
|
t.Fatalf("save meta: %v", err)
|
|
}
|
|
markTopicMigrationDone(dir)
|
|
|
|
if repaired := migrateLegacySessionsIntoGlobalTopics(dir); len(repaired) != 1 || repaired[0] != repairID {
|
|
t.Fatalf("repaired topics = %#v, want %q", repaired, repairID)
|
|
}
|
|
if got := loadTopicTitle("", tombstonedID); got != "" {
|
|
t.Fatalf("stale title = %q, repair save should prune the tombstoned entry", got)
|
|
}
|
|
if got := loadTopicTitle("", repairID); got != "待修复话题" {
|
|
t.Fatalf("repaired title = %q, want 待修复话题", got)
|
|
}
|
|
f := loadProjectsFile()
|
|
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
|
t.Fatalf("globalTopics = %#v, tombstoned topic must stay out", f.GlobalTopics)
|
|
}
|
|
if !containsDesktopString(f.GlobalTopics, repairID) {
|
|
t.Fatalf("globalTopics = %#v, want repaired topic %q", f.GlobalTopics, repairID)
|
|
}
|
|
}
|
|
|
|
func TestTopicMigrationDefersEmptyLegacySession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
// An empty legacy session (no user turns) is not migratable yet but could gain
|
|
// content later, so the pass must NOT mark the dir done — otherwise the gate
|
|
// would hide it forever.
|
|
if err := os.WriteFile(filepath.Join(dir, "empty.jsonl"), nil, 0o644); err != nil {
|
|
t.Fatalf("write empty session: %v", err)
|
|
}
|
|
|
|
NewApp().ListProjectTree()
|
|
if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err == nil {
|
|
t.Fatal("an empty legacy session must defer marking, but the dir was marked done")
|
|
}
|
|
}
|
|
|
|
func TestV05LegacyEventSessionsImportIntoGlobalTopic(t *testing.T) {
|
|
home := isolateDesktopUserDirs(t)
|
|
|
|
legacyDir := filepath.Join(home, ".reasonix", "sessions")
|
|
destDir := config.SessionDir()
|
|
writeLegacyEventSession(t, legacyDir, "v053-chat.events.jsonl", "hello from v0.53", "hi from v0.53", time.Now().Add(-time.Hour))
|
|
|
|
imported, err := agent.MigrateLegacySessions(legacyDir, destDir, config.ProjectSessionDir)
|
|
if err != nil {
|
|
t.Fatalf("migrate legacy sessions: %v", err)
|
|
}
|
|
if imported != 1 {
|
|
t.Fatalf("imported legacy sessions = %d, want 1", imported)
|
|
}
|
|
migratedSession := filepath.Join(destDir, "v053-chat.jsonl")
|
|
if _, err := os.Stat(migratedSession); err != nil {
|
|
t.Fatalf("legacy v0.5 session was not imported to %s: %v", migratedSession, err)
|
|
}
|
|
|
|
wantTopicID := legacySessionTopicID(migratedSession)
|
|
migratedTopics := migrateLegacySessionsIntoGlobalTopics(destDir)
|
|
if len(migratedTopics) != 1 || migratedTopics[0] != wantTopicID {
|
|
t.Fatalf("migrated topics = %#v, want imported v0.5 topic %q", migratedTopics, wantTopicID)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
|
t.Fatalf("project tree = %#v, want global folder", nodes)
|
|
}
|
|
if len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
|
|
t.Fatalf("global topics = %#v, want imported v0.5 topic %q", nodes[0].Children, wantTopicID)
|
|
}
|
|
meta, ok, err := agent.LoadBranchMeta(migratedSession)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load imported v0.5 meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if meta.Scope != "global" || meta.TopicID != wantTopicID {
|
|
t.Fatalf("imported v0.5 meta = %+v", meta)
|
|
}
|
|
}
|
|
|
|
func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
dotted := writeLegacySession(t, dir, "chat.1.jsonl", "dotted prompt", time.Now().Add(-2*time.Hour))
|
|
underscored := writeLegacySession(t, dir, "chat_1.jsonl", "underscored prompt", time.Now().Add(-time.Hour))
|
|
|
|
dottedTopic := legacySessionTopicID(dotted)
|
|
underscoredTopic := legacySessionTopicID(underscored)
|
|
if dottedTopic == underscoredTopic {
|
|
t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
|
|
}
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
|
t.Fatalf("project tree = %#v, want global folder", nodes)
|
|
}
|
|
if got := len(nodes[0].Children); got != 2 {
|
|
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, child := range nodes[0].Children {
|
|
seen[child.TopicID] = true
|
|
}
|
|
if !seen[dottedTopic] || !seen[underscoredTopic] {
|
|
t.Fatalf("global topics = %#v, want %q and %q", nodes[0].Children, dottedTopic, underscoredTopic)
|
|
}
|
|
}
|
|
|
|
func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeLegacySession(t, dir, "legacy-tab.jsonl", "resume this legacy tab", time.Now().Add(-time.Hour))
|
|
|
|
tab := &WorkspaceTab{
|
|
ID: "tab_legacy",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
Ready: false,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"tab_legacy": tab},
|
|
tabOrder: []string{"tab_legacy"},
|
|
activeTabID: "tab_legacy",
|
|
}
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl != nil {
|
|
defer tab.Ctrl.Close()
|
|
}
|
|
|
|
wantTopicID := legacySessionTopicID(sessionPath)
|
|
if tab.TopicID != wantTopicID {
|
|
t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID)
|
|
}
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built")
|
|
}
|
|
if tab.Ctrl.SessionPath() != sessionPath {
|
|
t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath)
|
|
}
|
|
f := loadTabsFile()
|
|
if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID {
|
|
t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f)
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerRestoresPinnedSessionBeforeTopicFallback(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
topicID := "topic_same"
|
|
topicTitle := "Pinned topic"
|
|
pinned := writeTopicSessionWithPrompt(t, dir, "long.jsonl", topicID, topicTitle, "", "full 64-turn conversation", time.Now().Add(-2*time.Hour))
|
|
_ = writeTopicSessionWithPrompt(t, dir, "short.jsonl", topicID, topicTitle, "", "early 5-turn snapshot", time.Now().Add(time.Hour))
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_pinned")
|
|
tab.TopicTitle = topicTitle
|
|
tab.SessionPath = pinned
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
|
|
}
|
|
defer tab.Ctrl.Close()
|
|
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
|
|
t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
|
|
}
|
|
history := tab.Ctrl.History()
|
|
if len(history) == 0 || history[0].Content != "full 64-turn conversation" {
|
|
t.Fatalf("restored history = %+v, want pinned long conversation", history)
|
|
}
|
|
f := loadTabsFile()
|
|
if len(f.Tabs) != 1 || filepath.Clean(f.Tabs[0].SessionPath) != filepath.Clean(pinned) {
|
|
t.Fatalf("desktop tabs file = %+v, want pinned session path %q", f, pinned)
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerUsesPinnedSessionMetaWorkspace(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := t.TempDir()
|
|
projectB := t.TempDir()
|
|
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)
|
|
}
|
|
|
|
topicID := "topic_restore_workspace"
|
|
topicTitle := "Restore workspace"
|
|
sessionDirA := desktopSessionDir(projectA)
|
|
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
|
t.Fatalf("mkdir project A sessions: %v", err)
|
|
}
|
|
pinned := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
|
|
tab.TopicTitle = topicTitle
|
|
tab.SessionPath = pinned
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
|
|
}
|
|
defer tab.Ctrl.Close()
|
|
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
|
|
t.Fatalf("restored session path = %q, want pinned %q", got, pinned)
|
|
}
|
|
if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
|
|
}
|
|
history := tab.Ctrl.History()
|
|
if len(history) == 0 || history[0].Content != "project A prompt" {
|
|
t.Fatalf("restored history = %+v, want project A prompt", history)
|
|
}
|
|
}
|
|
|
|
func TestPersistTabSessionPathUsesSessionDirOwnerBeforeSavingMeta(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectA := t.TempDir()
|
|
projectB := t.TempDir()
|
|
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)
|
|
}
|
|
|
|
topicID := "topic_owner_before_meta"
|
|
topicTitle := "Owner before meta"
|
|
sessionDirA := desktopSessionDir(projectA)
|
|
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
|
t.Fatalf("mkdir project A sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
|
|
meta, ok, err := agent.LoadBranchMeta(sessionPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load branch meta: ok=%v err=%v", ok, err)
|
|
}
|
|
meta.WorkspaceRoot = projectB
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
|
|
t.Fatalf("pollute branch meta: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("project", projectB, topicID, "tab_stale_workspace")
|
|
tab.TopicTitle = topicTitle
|
|
tab.SessionPath = sessionPath
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.persistTabSessionPath(tab, sessionPath)
|
|
|
|
if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
|
|
}
|
|
gotMeta, ok, err := agent.LoadBranchMeta(sessionPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("reload branch meta: ok=%v err=%v", ok, err)
|
|
}
|
|
if gotMeta.Scope != "project" || normalizeProjectRoot(gotMeta.WorkspaceRoot) != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("saved branch meta scope/root = %q/%q, want project/%q", gotMeta.Scope, gotMeta.WorkspaceRoot, normalizeProjectRoot(projectA))
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerIgnoresStaleSessionModelWhenTabModelResolves(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
t.Setenv("REASONIX_TEST_KEY", "sk-test")
|
|
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
|
t.Fatalf("mkdir config dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(config.UserConfigPath(), []byte(`
|
|
default_model = "default-provider/default-model"
|
|
|
|
[[providers]]
|
|
name = "default-provider"
|
|
kind = "openai"
|
|
base_url = "https://default.invalid/v1"
|
|
model = "default-model"
|
|
api_key_env = "REASONIX_TEST_KEY"
|
|
|
|
[[providers]]
|
|
name = "tab-provider"
|
|
kind = "openai"
|
|
base_url = "https://tab.invalid/v1"
|
|
model = "tab-model"
|
|
api_key_env = "REASONIX_TEST_KEY"
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
pinned := writeLegacySession(t, dir, "stale-model.jsonl", "resume with tab model", time.Now())
|
|
meta, err := agent.EnsureBranchMeta(pinned)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta.Model = "missing-provider/missing-model"
|
|
if err := agent.SaveBranchMetaPreserveUpdated(pinned, meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_stale_model")
|
|
tab.SessionPath = pinned
|
|
tab.model = "tab-provider/tab-model"
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
|
|
}
|
|
defer tab.Ctrl.Close()
|
|
if tab.model != "tab-provider/tab-model" {
|
|
t.Fatalf("tab model = %q, want valid tab model", tab.model)
|
|
}
|
|
}
|
|
|
|
func TestLoadPinnedTabSessionFallsBackToMigratedBasename(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := writeLegacySession(t, dir, "migrated-tab.jsonl", "resume after path migration", time.Now())
|
|
oldPath := filepath.Join(t.TempDir(), "old-reasonix", "projects", "slug", "sessions", filepath.Base(path))
|
|
|
|
loaded, pinnedPath, ok := loadPinnedTabSession(dir, oldPath)
|
|
if !ok || loaded == nil {
|
|
t.Fatalf("loadPinnedTabSession did not recover migrated basename: ok=%v loaded=%v path=%q", ok, loaded, pinnedPath)
|
|
}
|
|
if filepath.Clean(pinnedPath) != filepath.Clean(path) {
|
|
t.Fatalf("pinned path = %q, want %q", pinnedPath, path)
|
|
}
|
|
}
|
|
|
|
func TestPinnedTabSessionPathRejectsExistingAbsolutePathOutsideDir(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dirA := t.TempDir()
|
|
dirB := t.TempDir()
|
|
pathA := writeLegacySession(t, dirA, "same-name.jsonl", "project A", time.Now())
|
|
_ = writeLegacySession(t, dirB, filepath.Base(pathA), "project B", time.Now())
|
|
|
|
if got, ok := pinnedTabSessionPath(dirB, pathA); ok {
|
|
t.Fatalf("pinnedTabSessionPath mapped existing absolute path outside dir to %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadPinnedTabSessionSkipsCleanupPending(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := writeLegacySession(t, dir, "pending-pinned.jsonl", "pending pinned", time.Now())
|
|
if err := agent.MarkCleanupPending(path, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if loaded, pinnedPath, ok := loadPinnedTabSession(dir, path); ok || loaded != nil || pinnedPath != "" {
|
|
t.Fatalf("loadPinnedTabSession cleanup-pending = loaded:%v path:%q ok:%v, want skipped", loaded, pinnedPath, ok)
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerSkipsCleanupPendingPinnedSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
pending := writeLegacySession(t, dir, "pending-startup.jsonl", "pending startup", time.Now())
|
|
if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "tab_pending")
|
|
tab.SessionPath = pending
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
|
|
}
|
|
defer tab.Ctrl.Close()
|
|
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
|
|
t.Fatalf("startup bound cleanup-pending pinned session path %q", got)
|
|
}
|
|
for _, msg := range tab.Ctrl.History() {
|
|
if msg.Content == "pending startup" {
|
|
t.Fatalf("startup loaded cleanup-pending history: %+v", tab.Ctrl.History())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerKeepsMissingPinnedSessionPath(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
topicID := "topic_empty"
|
|
topicTitle := "Empty pinned topic"
|
|
_ = writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old topic history", time.Now())
|
|
pinned := filepath.Join(dir, "empty-new.jsonl")
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), topicID, "tab_empty")
|
|
tab.TopicTitle = topicTitle
|
|
tab.SessionPath = pinned
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built: %s", tab.StartupErr)
|
|
}
|
|
defer tab.Ctrl.Close()
|
|
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(pinned) {
|
|
t.Fatalf("empty pinned session path = %q, want %q", got, pinned)
|
|
}
|
|
for _, msg := range tab.Ctrl.History() {
|
|
if msg.Content == "old topic history" {
|
|
t.Fatalf("empty pinned session loaded fallback topic history: %+v", tab.Ctrl.History())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReorderProjectsPersistsSidebarAndWorkspaceOrder(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
third := t.TempDir()
|
|
if err := addProject(first, "First"); err != nil {
|
|
t.Fatalf("add first project: %v", err)
|
|
}
|
|
if err := addProject(second, "Second"); err != nil {
|
|
t.Fatalf("add second project: %v", err)
|
|
}
|
|
if err := addProject(third, "Third"); err != nil {
|
|
t.Fatalf("add third project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
if err := app.ReorderProjects([]string{third, first, second}); err != nil {
|
|
t.Fatalf("ReorderProjects: %v", err)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 3 {
|
|
t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
|
|
}
|
|
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
|
|
t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
|
|
}
|
|
workspaces := app.ListWorkspaces()
|
|
if len(workspaces) != 3 {
|
|
t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
|
|
}
|
|
if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
|
|
t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
|
|
}
|
|
}
|
|
|
|
func TestReorderProjectsPersistsGlobalSidebarOrder(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
if err := addProject(first, "First"); err != nil {
|
|
t.Fatalf("add first project: %v", err)
|
|
}
|
|
if err := addProject(second, "Second"); err != nil {
|
|
t.Fatalf("add second project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
if _, err := app.CreateTopic("global", "", "Global note"); err != nil {
|
|
t.Fatalf("create global topic: %v", err)
|
|
}
|
|
if err := app.ReorderProjects([]string{second, desktopGlobalOrderToken, first}); err != nil {
|
|
t.Fatalf("ReorderProjects with global: %v", err)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 3 {
|
|
t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
|
|
}
|
|
if got := []string{nodes[0].Root, nodes[1].Kind, nodes[2].Root}; got[0] != second || got[1] != "global_folder" || got[2] != first {
|
|
t.Fatalf("project tree order = %v, want [%s global_folder %s]", got, second, first)
|
|
}
|
|
workspaces := app.ListWorkspaces()
|
|
if len(workspaces) != 2 {
|
|
t.Fatalf("workspaces len = %d, want 2: %+v", len(workspaces), workspaces)
|
|
}
|
|
if got := []string{workspaces[0].Path, workspaces[1].Path}; got[0] != second || got[1] != first {
|
|
t.Fatalf("workspace order = %v, want %v", got, []string{second, first})
|
|
}
|
|
}
|
|
|
|
func TestReorderProjectsRejectsInvalidOrder(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
if err := addProject(first, "First"); err != nil {
|
|
t.Fatalf("add first project: %v", err)
|
|
}
|
|
if err := addProject(second, "Second"); err != nil {
|
|
t.Fatalf("add second project: %v", err)
|
|
}
|
|
app := NewApp()
|
|
for name, order := range map[string][]string{
|
|
"missing": {first},
|
|
"unknown": {first, filepath.Join(t.TempDir(), "missing")},
|
|
"duplicate": {first, first},
|
|
"duplicate-global": {desktopGlobalOrderToken, first, desktopGlobalOrderToken, second},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := app.ReorderProjects(order); err == nil {
|
|
t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
|
|
}
|
|
})
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
|
|
t.Fatalf("project tree order changed after invalid reorder: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoveWorkspaceUsesSharedProjectRegistryForCurrentProject(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Current Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
|
t.Fatalf("remove current project: %v", err)
|
|
}
|
|
if got := app.ListWorkspaces(); len(got) != 0 {
|
|
t.Fatalf("workspaces after remove = %+v, want empty", got)
|
|
}
|
|
if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
|
|
t.Fatalf("project tree after remove = %+v, want empty Global folder", got)
|
|
}
|
|
}
|
|
|
|
func TestRestoredProjectTabUsesStoredTopicTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_stored_title"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
tabs := app.ListTabs()
|
|
if len(tabs) != 1 {
|
|
t.Fatalf("tabs len = %d, want 1", len(tabs))
|
|
}
|
|
if got := tabs[0].TopicTitle; got != "你是谁" {
|
|
t.Fatalf("tab title = %q, want 你是谁", got)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
|
|
t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
|
|
}
|
|
}
|
|
|
|
func TestUntitledProjectTopicUsesSameFallbackEverywhere(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_without_title"
|
|
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
|
|
Root: projectRoot,
|
|
Topics: []string{topicID},
|
|
}}}); err != nil {
|
|
t.Fatalf("save projects: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
tabs := app.ListTabs()
|
|
if len(tabs) != 1 {
|
|
t.Fatalf("tabs len = %d, want 1", len(tabs))
|
|
}
|
|
if got := tabs[0].TopicTitle; got != defaultTopicTitle {
|
|
t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
|
|
t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
}
|
|
|
|
func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
before := time.Now().UnixMilli()
|
|
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
|
after := time.Now().UnixMilli()
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
if got := topic.Title; got != defaultTopicTitle {
|
|
t.Fatalf("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)
|
|
}
|
|
if got := loadTopicCreatedAt(projectRoot, topic.ID); got < before || got > after {
|
|
t.Fatalf("createdAt = %d, want between %d and %d", got, before, after)
|
|
}
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].CreatedAt; got != topic.CreatedAt {
|
|
t.Fatalf("project tree createdAt = %d, want %d", got, topic.CreatedAt)
|
|
}
|
|
}
|
|
|
|
func TestListProjectTreeFallsBackToTopicIDCreatedAt(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
const topicID = "legacy_20260606-114914_2276f13fd87c"
|
|
if err := setTopicTitleWithSource("", topicID, "你好,你是谁", topicTitleSourceManual); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
|
t.Fatalf("prepend topic: %v", err)
|
|
}
|
|
|
|
nodes := NewApp().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)
|
|
}
|
|
expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
|
|
if got := nodes[0].Children[0].CreatedAt; got != expected {
|
|
t.Fatalf("project tree createdAt = %d, want %d", got, expected)
|
|
}
|
|
}
|
|
|
|
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
first, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create first topic: %v", err)
|
|
}
|
|
second, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create second topic: %v", err)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
|
|
t.Fatalf("project tree = %#v, want one project with two topics", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].TopicID; got != second.ID {
|
|
t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
|
|
}
|
|
if got := nodes[0].Children[1].TopicID; got != first.ID {
|
|
t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateGlobalTopicAppearsFirstInProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
app := NewApp()
|
|
first, err := app.CreateTopic("global", "", "")
|
|
if err != nil {
|
|
t.Fatalf("create first global topic: %v", err)
|
|
}
|
|
second, err := app.CreateTopic("global", "", "")
|
|
if err != nil {
|
|
t.Fatalf("create second global topic: %v", err)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
|
|
t.Fatalf("project tree = %#v, want Global with two topics", nodes)
|
|
}
|
|
if got := nodes[0].Children[0].TopicID; got != second.ID {
|
|
t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
|
|
}
|
|
if got := nodes[0].Children[1].TopicID; got != first.ID {
|
|
t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
|
|
}
|
|
}
|
|
|
|
func TestListProjectTreeShowsEmptyGlobalWhenNoProjects(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("project tree = %#v, want one Global folder", nodes)
|
|
}
|
|
if nodes[0].Kind != "global_folder" || nodes[0].Label != "Global" || len(nodes[0].Children) != 0 {
|
|
t.Fatalf("project tree = %#v, want empty Global folder", nodes)
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkspaceRegistersDefaultTopicInProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
if got, err := app.SwitchWorkspace(projectRoot); err != nil {
|
|
t.Fatalf("SwitchWorkspace: %v", err)
|
|
} else if got != projectRoot {
|
|
t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
|
|
}
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
|
|
}
|
|
if got := nodes[0].Root; got != projectRoot {
|
|
t.Fatalf("project root = %q, want %q", got, projectRoot)
|
|
}
|
|
if len(nodes[0].Children) != 1 {
|
|
t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
|
|
}
|
|
child := nodes[0].Children[0]
|
|
if got := child.Label; got != defaultTopicTitle {
|
|
t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if strings.TrimSpace(child.TopicID) == "" {
|
|
t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
|
|
}
|
|
tabs := app.ListTabs()
|
|
if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
|
|
t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
|
|
}
|
|
}
|
|
|
|
func TestRenameTopicLocksTitleManual(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 := app.RenameTopic(topic.ID, "手动标题"); err != nil {
|
|
t.Fatalf("rename topic: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
|
|
t.Fatalf("stored title = %q, want 手动标题", got)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestRenameTopicUpdatesOpenTabMeta(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)
|
|
}
|
|
tab, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
waitForTabReady(t, app, tab.ID)
|
|
if tab.TopicTitle != "旧标题" {
|
|
t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
|
|
}
|
|
|
|
if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
|
|
t.Fatalf("rename topic: %v", err)
|
|
}
|
|
tabs := app.ListTabs()
|
|
if len(tabs) != 1 {
|
|
t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
|
|
}
|
|
if got := tabs[0].TopicTitle; got != "新标题" {
|
|
t.Fatalf("open tab title = %q, want 新标题", got)
|
|
}
|
|
}
|
|
|
|
func TestRenameTopicRecreatesDeletedProjectTitleIndexFromOpenTab(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)
|
|
}
|
|
tab, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
waitForTabReady(t, app, tab.ID)
|
|
if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic titles: %v", err)
|
|
}
|
|
if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic title sources: %v", err)
|
|
}
|
|
|
|
if err := app.RenameTopic(topic.ID, "恢复标题"); err != nil {
|
|
t.Fatalf("rename topic after deleting title index: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "恢复标题" {
|
|
t.Fatalf("restored topic title = %q, want 恢复标题", got)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topic.ID {
|
|
t.Fatalf("project tree should still contain topic, got %#v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestRenameTopicRecreatesDeletedProjectTitleIndexFromSessionMeta(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_missing_index"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "旧标题"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
writeTopicSession(t, dir, "missing-index.jsonl", topicID, "旧标题", projectRoot)
|
|
if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic titles: %v", err)
|
|
}
|
|
if err := os.Remove(topicTitleSourcesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic title sources: %v", err)
|
|
}
|
|
|
|
if err := NewApp().RenameTopic(topicID, "恢复标题"); err != nil {
|
|
t.Fatalf("rename topic from session meta after deleting title index: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "恢复标题" {
|
|
t.Fatalf("restored topic title = %q, want 恢复标题", got)
|
|
}
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("project tree should contain restored topic, got %#v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestOpenProjectTabRecoversMissingTopicTitleFromSessionMeta(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := robustTempDir(t)
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "旧标题")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-meta.jsonl", topic.ID, "用户保存标题", projectRoot, "first prompt should not win", time.Now())
|
|
if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic titles: %v", err)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("precondition title source = %q, want manual", got)
|
|
}
|
|
|
|
meta, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
tab := waitForTabReady(t, app, meta.ID)
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
|
|
t.Fatalf("opened session path = %q, want %q", got, sessionPath)
|
|
}
|
|
if got := meta.TopicTitle; got != "用户保存标题" {
|
|
t.Fatalf("opened topic title = %q, want 用户保存标题", got)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "用户保存标题" {
|
|
t.Fatalf("stored topic title = %q, want 用户保存标题", got)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenProjectTabRecoversMissingTopicTitleFromSessionTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := robustTempDir(t)
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "旧标题")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSessionWithPrompt(t, dir, "stored-session-title.jsonl", topic.ID, "", projectRoot, "first prompt should not win", time.Now())
|
|
if err := setSessionTitle(dir, sessionPath, "历史手动标题"); err != nil {
|
|
t.Fatalf("set session title: %v", err)
|
|
}
|
|
if err := os.Remove(topicTitlesPath(projectRoot)); err != nil {
|
|
t.Fatalf("remove topic titles: %v", err)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("precondition title source = %q, want manual", got)
|
|
}
|
|
|
|
meta, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
waitForTabReady(t, app, meta.ID)
|
|
if got := meta.TopicTitle; got != "历史手动标题" {
|
|
t.Fatalf("opened topic title = %q, want 历史手动标题", got)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "历史手动标题" {
|
|
t.Fatalf("stored topic title = %q, want 历史手动标题", got)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenProjectTabPreservesManualDefaultTopicTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := robustTempDir(t)
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
if err := app.RenameTopic(topic.ID, defaultTopicTitle); err != nil {
|
|
t.Fatalf("rename topic: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
writeTopicSessionWithPrompt(t, dir, "manual-default.jsonl", topic.ID, defaultTopicTitle, projectRoot, "first prompt should not replace manual default", time.Now())
|
|
|
|
meta, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
waitForTabReady(t, app, meta.ID)
|
|
if got := meta.TopicTitle; got != defaultTopicTitle {
|
|
t.Fatalf("opened topic title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
|
t.Fatalf("stored topic title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureTopicIndexedPreservesGlobalAutoTitleSource(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
topicID := "topic_global_auto"
|
|
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
|
t.Fatalf("set global topic title: %v", err)
|
|
}
|
|
source := loadTopicTitleSource(topicTitleRoot("global", globalTabWorkspaceRoot()), topicID)
|
|
if err := ensureTopicIndexed("global", globalTabWorkspaceRoot(), topicID, defaultTopicTitle, source); err != nil {
|
|
t.Fatalf("ensure global topic indexed: %v", err)
|
|
}
|
|
|
|
if got := loadTopicTitleSource("", topicID); got != topicTitleSourceAuto {
|
|
t.Fatalf("global title source = %q, want %q", got, topicTitleSourceAuto)
|
|
}
|
|
}
|
|
|
|
func TestAutoTitleTopicFromFirstUserMessage(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
|
|
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
|
if !updated {
|
|
t.Fatal("auto title should update")
|
|
}
|
|
if title != "讲讲这个代码库的架构" {
|
|
t.Fatalf("generated title = %q", title)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != title {
|
|
t.Fatalf("stored title = %q, want %q", got, title)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
|
t.Fatalf("title source = %q, want auto", got)
|
|
}
|
|
}
|
|
|
|
func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
prompt := control.New(control.Options{ReasoningLanguage: "zh"}).Compose("讲讲这个代码库的架构")
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
|
|
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
|
if !updated {
|
|
t.Fatal("auto title should update")
|
|
}
|
|
if title != "讲讲这个代码库的架构" {
|
|
t.Fatalf("generated title = %q", title)
|
|
}
|
|
}
|
|
|
|
func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
firstTurn := strings.Join([]string{
|
|
`{"role":"user","content":"帮我看看"}`,
|
|
`{"role":"assistant","content":"可以"}`,
|
|
}, "\n") + "\n"
|
|
if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
|
|
t.Fatalf("write first session: %v", err)
|
|
}
|
|
|
|
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
|
if !updated || title != "帮我看看" {
|
|
t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
|
|
}
|
|
|
|
thirdTurn := strings.Join([]string{
|
|
`{"role":"user","content":"帮我看看"}`,
|
|
`{"role":"assistant","content":"可以"}`,
|
|
`{"role":"user","content":"继续"}`,
|
|
`{"role":"assistant","content":"继续分析"}`,
|
|
`{"role":"user","content":"实现自动更新会话标题"}`,
|
|
`{"role":"assistant","content":"已实现"}`,
|
|
}, "\n") + "\n"
|
|
if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
|
|
t.Fatalf("write third session: %v", err)
|
|
}
|
|
|
|
title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
|
if !updated || title != "实现自动更新会话标题" {
|
|
t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
|
|
t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
|
|
}
|
|
meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
|
|
if meta.Stage != 3 {
|
|
t.Fatalf("auto title stage = %d, want 3", meta.Stage)
|
|
}
|
|
}
|
|
|
|
func TestAutoTitleDoesNotOverrideManualTopicTitle(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 := app.RenameTopic(topic.ID, "手动标题"); err != nil {
|
|
t.Fatalf("rename topic: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
|
|
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
|
|
t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
|
|
t.Fatalf("stored title = %q, want 手动标题", got)
|
|
}
|
|
}
|
|
|
|
func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
|
|
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
|
|
t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
|
t.Fatalf("stored title = %q, want default title", got)
|
|
}
|
|
}
|
|
|
|
func TestRenameTopicBlankKeepsManualTitleSource(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 := app.RenameTopic(topic.ID, " "); err != nil {
|
|
t.Fatalf("rename blank topic: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
|
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
|
}
|
|
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
|
t.Fatalf("title source = %q, want manual", got)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_trash_history"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
|
|
placeholderPath := filepath.Join(dir, "trash-placeholder-session.jsonl")
|
|
if err := os.WriteFile(placeholderPath, nil, 0o644); err != nil {
|
|
t.Fatalf("write placeholder session: %v", err)
|
|
}
|
|
now := time.Now()
|
|
if err := agent.SaveBranchMetaPreserveUpdated(placeholderPath, agent.BranchMeta{
|
|
CreatedAt: now.Add(-time.Minute),
|
|
UpdatedAt: now,
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Trash history",
|
|
}); err != nil {
|
|
t.Fatalf("save placeholder branch meta: %v", err)
|
|
}
|
|
placeholderGoalPath := strings.TrimSuffix(placeholderPath, ".jsonl") + ".goal-state.json"
|
|
if err := os.WriteFile(placeholderGoalPath, []byte(`{"done":true}`), 0o644); err != nil {
|
|
t.Fatalf("write placeholder goal state: %v", err)
|
|
}
|
|
ref := "sa_20260102_030405_000000000_aabbccddeeff"
|
|
writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath))
|
|
|
|
if err := NewApp().TrashTopic(topicID); err != nil {
|
|
t.Fatalf("trash topic: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("topic session should be removed from active history, stat err = %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("topic session should be moved to trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(placeholderPath); !os.IsNotExist(err) {
|
|
t.Fatalf("placeholder session should be removed from active history, stat err = %v", err)
|
|
}
|
|
placeholderTrashDir := filepath.Join(dir, sessionTrashDir, "trash-placeholder-session.jsonl")
|
|
if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl")); err != nil {
|
|
t.Fatalf("placeholder session should be moved to trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.jsonl.meta")); err != nil {
|
|
t.Fatalf("placeholder meta should be moved to trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(placeholderTrashDir, "trash-placeholder-session.goal-state.json")); err != nil {
|
|
t.Fatalf("placeholder goal state should be moved to trash: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "subagents", ref+".jsonl")); err != nil {
|
|
t.Fatalf("topic subagent should be moved to trash: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
|
t.Fatalf("topic title should be removed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicRemovesStaleMissingSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_missing_trash"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Missing trash"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
missingPath := filepath.Join(dir, "already-gone.jsonl")
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"stale": {
|
|
ID: "stale",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Missing trash",
|
|
SessionPath: missingPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
|
|
},
|
|
tabOrder: []string{"stale", "other"},
|
|
activeTabID: "stale",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("TrashTopic should remove stale missing session: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
|
t.Fatalf("topic title should be removed, got %q", got)
|
|
}
|
|
if _, ok := app.tabs["stale"]; ok {
|
|
t.Fatalf("stale tab should be removed")
|
|
}
|
|
if got := app.activeTabID; got != "other" {
|
|
t.Fatalf("active tab = %q, want other", got)
|
|
}
|
|
}
|
|
|
|
func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeLegacySession(t, dir, "restore-global.jsonl", "restore global history", time.Now().Add(-time.Hour))
|
|
topicID := legacySessionTopicID(sessionPath)
|
|
app := NewApp()
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("legacy session should start in Global, got %#v", nodes)
|
|
}
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("trash global topic: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "restore-global.jsonl", "restore-global.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("global session should be in trash: %v", err)
|
|
}
|
|
if got := app.ListProjectTree(); len(got) != 1 || got[0].Kind != "global_folder" || len(got[0].Children) != 0 {
|
|
t.Fatalf("trashed global topic should leave empty Global folder, got %#v", got)
|
|
}
|
|
|
|
if err := app.RestoreSession(trashPath); err != nil {
|
|
t.Fatalf("restore global session: %v", err)
|
|
}
|
|
if got := app.ListTrashedSessions(); len(got) != 0 {
|
|
t.Fatalf("trash should be empty after restore, got %#v", got)
|
|
}
|
|
nodes = app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_restore_project"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Project restore"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
writeTopicSession(t, dir, "restore-project.jsonl", topicID, "Project restore", projectRoot)
|
|
app := NewApp()
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("trash project topic: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "restore-project.jsonl", "restore-project.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("project session should be in trash: %v", err)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
|
t.Fatalf("topic title should be removed while trashed, got %q", got)
|
|
}
|
|
|
|
if err := app.RestoreSession(trashPath); err != nil {
|
|
t.Fatalf("restore project session: %v", err)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "Project restore" {
|
|
t.Fatalf("restored topic title = %q, want Project restore", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenProjectTabResolvesProjectSessionFromLegacyDir(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_legacy_project"
|
|
topicTitle := "Legacy project topic"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, topicTitle); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSessionWithPrompt(t, dir, "legacy-project.jsonl", topicID, topicTitle, projectRoot, "legacy project prompt", time.Now())
|
|
app := NewApp()
|
|
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("legacy project session should appear in project tree, got %#v", nodes)
|
|
}
|
|
meta, err := app.OpenProjectTab(projectRoot, topicID)
|
|
if err != nil {
|
|
t.Fatalf("OpenProjectTab: %v", err)
|
|
}
|
|
tab := waitForTabReady(t, app, meta.ID)
|
|
if tab.Ctrl == nil {
|
|
t.Fatalf("tab controller was not built")
|
|
}
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got != filepath.Clean(sessionPath) {
|
|
t.Fatalf("opened session path = %q, want %q", got, sessionPath)
|
|
}
|
|
history := tab.Ctrl.History()
|
|
if len(history) == 0 || history[0].Content != "legacy project prompt" {
|
|
t.Fatalf("opened history = %+v, want legacy project prompt", history)
|
|
}
|
|
}
|
|
|
|
func TestRestoreSessionWithoutTopicMetadataFallsBackToGlobal(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeLegacySession(t, dir, "restore-orphan.jsonl", "restore orphan history", time.Now().Add(-time.Hour))
|
|
topicID := legacySessionTopicID(sessionPath)
|
|
app := NewApp()
|
|
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
|
|
app.setTestCtrl(ctrl, "")
|
|
defer ctrl.Close()
|
|
if err := app.DeleteSession(sessionPath); err != nil {
|
|
t.Fatalf("delete orphan session: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "restore-orphan.jsonl", "restore-orphan.jsonl")
|
|
|
|
if err := app.RestoreSession(trashPath); err != nil {
|
|
t.Fatalf("restore orphan session: %v", err)
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
|
t.Fatalf("restored orphan session should fall back to Global, got %#v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_open_trash"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(dir, "open-trash.jsonl")
|
|
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
UpdatedAt: time.Now(),
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Open trash",
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
openTab := &WorkspaceTab{
|
|
ID: "tab_open",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Open trash",
|
|
Ctrl: controllerWithContent(t, sessionPath),
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
otherTab := &WorkspaceTab{
|
|
ID: "tab_other",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: "topic_keep",
|
|
TopicTitle: "Keep",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
|
|
tabOrder: []string{"tab_open", "tab_other"},
|
|
activeTabID: "tab_open",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("trash topic: %v", err)
|
|
}
|
|
if _, ok := app.tabs["tab_open"]; ok {
|
|
t.Fatalf("open tab for trashed topic should be removed")
|
|
}
|
|
if got := app.activeTabID; got != "tab_other" {
|
|
t.Fatalf("active tab = %q, want tab_other", got)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("open topic session should be moved to trash: %v", err)
|
|
}
|
|
trashed := app.ListTrashedSessions()
|
|
if len(trashed) != 1 || trashed[0].Path != trashPath {
|
|
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
|
|
}
|
|
preview, err := app.PreviewSession(trashPath)
|
|
if err != nil {
|
|
t.Fatalf("preview trashed session: %v", err)
|
|
}
|
|
if !hasHistoryContent(preview, "remember this turn") {
|
|
t.Fatalf("preview trashed session = %#v, want remembered turn", preview)
|
|
}
|
|
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
|
t.Fatalf("topic title should be removed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicCancelsRunningSessionRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_running_trash"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Running trash"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "running-trash.jsonl", topicID, "Running trash", projectRoot)
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
|
defer ctrl.Close()
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"running": {
|
|
ID: "running",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Running trash",
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"keep": {
|
|
ID: "keep",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: "topic_keep",
|
|
TopicTitle: "Keep",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"running", "keep"},
|
|
activeTabID: "running",
|
|
}
|
|
|
|
ctrl.Submit("long turn")
|
|
<-runner.started
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("trash topic: %v", err)
|
|
}
|
|
waitNotRunning(t, ctrl)
|
|
if _, ok := app.tabs["running"]; ok {
|
|
t.Fatalf("running topic runtime should be removed")
|
|
}
|
|
if got := app.activeTabID; got != "keep" {
|
|
t.Fatalf("active tab = %q, want keep", got)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("running topic session should be moved out of active history, stat err = %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, "running-trash.jsonl", "running-trash.jsonl")
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("running topic session should be moved to trash: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicFallbackCreatesUnindexedBlank(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_only"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
|
|
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
|
defer ctrl.Close()
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"only": {
|
|
ID: "only",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Only topic",
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"only"},
|
|
activeTabID: "only",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("TrashTopic: %v", err)
|
|
}
|
|
if len(app.tabs) != 1 {
|
|
t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
|
|
}
|
|
for id, tab := range app.tabs {
|
|
if strings.TrimSpace(tab.TopicID) != "" {
|
|
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
|
|
}
|
|
if strings.TrimSpace(tab.SessionPath) == "" {
|
|
t.Fatalf("fallback tab %q has no precreated session path", id)
|
|
}
|
|
f := loadProjectsFile()
|
|
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
|
|
t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
|
|
}
|
|
}
|
|
nodes := app.ListProjectTree()
|
|
if len(nodes) != 1 || len(nodes[0].Children) != 0 {
|
|
t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
|
|
}
|
|
}
|
|
|
|
func TestTransientFallbackIndexesOnFirstUserTurn(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_only"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
|
|
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
|
defer ctrl.Close()
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"only": {
|
|
ID: "only",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Only topic",
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"only"},
|
|
activeTabID: "only",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("TrashTopic: %v", err)
|
|
}
|
|
var fallback *WorkspaceTab
|
|
for _, tab := range app.tabs {
|
|
fallback = tab
|
|
}
|
|
if fallback == nil {
|
|
t.Fatal("fallback tab missing")
|
|
}
|
|
if fallback.TopicID != "" {
|
|
t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
|
|
}
|
|
|
|
app.ensureTabTopicIndexedForUserTurn(fallback)
|
|
if strings.TrimSpace(fallback.TopicID) == "" {
|
|
t.Fatal("first user turn should assign a topic ID")
|
|
}
|
|
f := loadProjectsFile()
|
|
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
|
|
t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
|
|
}
|
|
meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
|
|
if err != nil || !ok {
|
|
t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
|
|
}
|
|
if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
|
|
t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
|
|
}
|
|
}
|
|
|
|
func TestTransientFallbackDiscardedWhenSingleSurfaceNavigatesAway(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
transientPath, err := createEmptySessionFile(dir, "test")
|
|
if err != nil {
|
|
t.Fatalf("create transient session: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
}); err != nil {
|
|
t.Fatalf("save transient meta: %v", err)
|
|
}
|
|
|
|
targetTopicID := "topic_target"
|
|
if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
|
|
t.Fatalf("set target topic title: %v", err)
|
|
}
|
|
targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"transient": {
|
|
ID: "transient",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicTitle: defaultTopicTitle,
|
|
SessionPath: transientPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"target": {
|
|
ID: "target",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: targetTopicID,
|
|
TopicTitle: "Target",
|
|
SessionPath: targetPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"transient", "target"},
|
|
activeTabID: "transient",
|
|
}
|
|
|
|
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
|
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
|
}
|
|
if _, ok := app.tabs["transient"]; ok {
|
|
t.Fatal("transient tab should be removed after single-surface navigation")
|
|
}
|
|
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
|
|
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("transient session meta should be removed, stat err = %v", err)
|
|
}
|
|
f := loadProjectsFile()
|
|
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
|
|
t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
|
|
}
|
|
}
|
|
|
|
func TestCloseTabDiscardsUnusedTransientBlankSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
dir := desktopSessionDir(projectRoot)
|
|
transientPath, err := createEmptySessionFile(dir, "test")
|
|
if err != nil {
|
|
t.Fatalf("create transient session: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
}); err != nil {
|
|
t.Fatalf("save transient meta: %v", err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"transient": {
|
|
ID: "transient",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicTitle: defaultTopicTitle,
|
|
SessionPath: transientPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"other": {
|
|
ID: "other",
|
|
Scope: "global",
|
|
TopicID: "topic_other",
|
|
TopicTitle: "Other",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"transient", "other"},
|
|
activeTabID: "transient",
|
|
}
|
|
|
|
if err := app.CloseTab("transient"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
if _, ok := app.tabs["transient"]; ok {
|
|
t.Fatal("transient tab should be closed")
|
|
}
|
|
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
|
|
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
|
|
t.Fatalf("transient session meta should be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCloseTabKeepsIndexedBlankSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_indexed_blank"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
indexedPath, err := createEmptySessionFile(dir, "test")
|
|
if err != nil {
|
|
t.Fatalf("create indexed blank session: %v", err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"indexed": {
|
|
ID: "indexed",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: defaultTopicTitle,
|
|
SessionPath: indexedPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"other": {
|
|
ID: "other",
|
|
Scope: "global",
|
|
TopicID: "topic_other",
|
|
TopicTitle: "Other",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"indexed", "other"},
|
|
activeTabID: "indexed",
|
|
}
|
|
|
|
if err := app.CloseTab("indexed"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
if _, err := os.Stat(indexedPath); err != nil {
|
|
t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTrashTopicTrashConflictKeepsRunningRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_trash_conflict"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Trash conflict"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := writeTopicSession(t, dir, "trash-conflict.jsonl", topicID, "Trash conflict", projectRoot)
|
|
if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath)), 0o755); err != nil {
|
|
t.Fatalf("create trash conflict: %v", err)
|
|
}
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
|
|
defer ctrl.Close()
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"running": {
|
|
ID: "running",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Trash conflict",
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
},
|
|
tabOrder: []string{"running"},
|
|
activeTabID: "running",
|
|
}
|
|
|
|
ctrl.Submit("long turn")
|
|
<-runner.started
|
|
err := app.TrashTopic(topicID)
|
|
if err != nil {
|
|
t.Fatalf("TrashTopic should succeed after cleaning empty trash dir: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("session file should be moved to trash, stat err = %v", err)
|
|
}
|
|
|
|
close(runner.release)
|
|
waitNotRunning(t, ctrl)
|
|
}
|
|
|
|
func TestTrashTopicValidTrashRemovesEmptyLiveStub(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
topicID := "topic_valid_trash"
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := setTopicTitle(projectRoot, topicID, "Valid trash"); err != nil {
|
|
t.Fatalf("set topic title: %v", err)
|
|
}
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(dir, "valid-trash.jsonl")
|
|
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
|
t.Fatalf("write live stub: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
UpdatedAt: time.Now(),
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Valid trash",
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
|
|
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
|
|
t.Fatalf("create trash dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"already trashed"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write trash session: %v", err)
|
|
}
|
|
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"stale": {
|
|
ID: "stale",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: "Valid trash",
|
|
SessionPath: sessionPath,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
},
|
|
"other": {ID: "other", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "other", Ready: true},
|
|
},
|
|
tabOrder: []string{"stale", "other"},
|
|
activeTabID: "other",
|
|
}
|
|
|
|
if err := app.TrashTopic(topicID); err != nil {
|
|
t.Fatalf("TrashTopic should remove stale live stub: %v", err)
|
|
}
|
|
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
|
t.Fatalf("live stub should be removed, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(trashPath); err != nil {
|
|
t.Fatalf("existing trash should remain authoritative: %v", err)
|
|
}
|
|
}
|
|
|
|
func hasHistoryContent(messages []HistoryMessage, content string) bool {
|
|
for _, m := range messages {
|
|
if m.Content == content {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestLegacyMigrationSkipsProjectScopedSessions(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := writeLegacySession(t, dir, "scoped.jsonl", "hello", time.Now())
|
|
meta, err := agent.EnsureBranchMeta(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta.Scope = "project"
|
|
meta.WorkspaceRoot = filepath.Join(t.TempDir(), "proj")
|
|
meta.TopicID = ""
|
|
if err := agent.SaveBranchMeta(path, meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
migrateLegacySessionsIntoGlobalTopics(dir)
|
|
|
|
got, err := agent.EnsureBranchMeta(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Scope != "project" || got.WorkspaceRoot != meta.WorkspaceRoot {
|
|
t.Fatalf("project-scoped legacy session must not be forced into Global: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestProjectTreeMigratesCLISessionFromProjectDir(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
dir := config.ProjectSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sessionPath := writeLegacySession(t, dir, "cli-project.jsonl", "cli project prompt", time.Now())
|
|
wantTopicID := legacySessionTopicID(sessionPath)
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
|
|
t.Fatalf("project CLI session should appear in project tree, got %#v; want topic %q", nodes, wantTopicID)
|
|
}
|
|
}
|
|
|
|
func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, ""); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
dir := config.ProjectSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := writeLegacySession(t, dir, "first-cli-project.jsonl", "first cli project prompt", time.Now().Add(-time.Hour))
|
|
firstTopicID := legacySessionTopicID(first)
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != firstTopicID {
|
|
t.Fatalf("first project CLI session should appear in project tree, got %#v; want topic %q", nodes, firstTopicID)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil {
|
|
t.Fatalf("expected migration marker after first project pass: %v", err)
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
second := writeLegacySession(t, dir, "second-cli-project.jsonl", "second cli project prompt", time.Now())
|
|
secondTopicID := legacySessionTopicID(second)
|
|
|
|
nodes = NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 2 {
|
|
t.Fatalf("second project CLI session should trigger re-scan, got %#v", nodes)
|
|
}
|
|
if nodes[0].Children[0].TopicID != secondTopicID || nodes[0].Children[1].TopicID != firstTopicID {
|
|
t.Fatalf("project CLI topics = %#v, want newest %q then %q", nodes[0].Children, secondTopicID, firstTopicID)
|
|
}
|
|
}
|
|
|
|
func TestProjectTreeMigratesCLISessionFromGlobalWorkspaceDir(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
globalRoot := globalWorkspaceRoot()
|
|
dir := desktopSessionDir(globalRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sessionPath := writeLegacySession(t, dir, "cli-global.jsonl", "cli global prompt", time.Now())
|
|
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
UpdatedAt: time.Now(),
|
|
Scope: "global",
|
|
WorkspaceRoot: globalRoot,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantTopicID := legacySessionTopicID(sessionPath)
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
|
|
t.Fatalf("global workspace CLI session should appear in Global, got %#v; want topic %q", nodes, wantTopicID)
|
|
}
|
|
}
|
|
|
|
func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const n = 8
|
|
want := make(map[string]bool, n)
|
|
for i := 0; i < n; i++ {
|
|
p := writeLegacySession(t, dir, fmt.Sprintf("legacy-%d.jsonl", i), "hi", time.Now())
|
|
want[legacySessionTopicID(p)] = true
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
migrateLegacySessionsIntoGlobalTopics(dir)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
gotSet := map[string]bool{}
|
|
for _, id := range loadProjectsFile().GlobalTopics {
|
|
gotSet[id] = true
|
|
}
|
|
for id := range want {
|
|
if !gotSet[id] {
|
|
t.Fatalf("concurrent migration lost topic %q; GlobalTopics=%v", id, loadProjectsFile().GlobalTopics)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFindTopicSessionIndexRefreshesWhenMetaChanges(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
topicID := "topic_cache_refresh"
|
|
now := time.Now().UTC()
|
|
first := writeTopicSessionWithPrompt(t, dir, "first.jsonl", topicID, "First", "", "first prompt", now.Add(-time.Hour))
|
|
|
|
if got := findTopicSession(dir, topicID); got != first {
|
|
t.Fatalf("first lookup = %q, want %q", got, first)
|
|
}
|
|
|
|
second := writeTopicSessionWithPrompt(t, dir, "second.jsonl", topicID, "Second", "", "second prompt", now)
|
|
if got := findTopicSession(dir, topicID); got != second {
|
|
t.Fatalf("lookup after new session = %q, want newer %q", got, second)
|
|
}
|
|
|
|
meta, ok, err := agent.LoadBranchMeta(second)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load second meta: ok=%v err=%v", ok, err)
|
|
}
|
|
meta.TopicID = "topic_cache_other"
|
|
meta.UpdatedAt = now.Add(time.Hour)
|
|
if err := agent.SaveBranchMetaPreserveUpdated(second, meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
future := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(agent.BranchMetaPath(second), future, future); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := findTopicSession(dir, topicID); got != first {
|
|
t.Fatalf("lookup after retopic = %q, want remaining %q", got, first)
|
|
}
|
|
if got := findTopicSession(dir, "topic_cache_other"); got != second {
|
|
t.Fatalf("lookup for retopic session = %q, want %q", got, second)
|
|
}
|
|
}
|
|
|
|
func TestFindTopicSessionSkipsCleanupPending(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
topicID := "topic_skip_pending"
|
|
now := time.Now().UTC()
|
|
normal := writeTopicSessionWithPrompt(t, dir, "normal.jsonl", topicID, "Normal", "", "normal prompt", now)
|
|
pending := writeTopicSessionWithPrompt(t, dir, "pending.jsonl", topicID, "Pending", "", "pending prompt", now.Add(time.Hour))
|
|
|
|
if got := findTopicSession(dir, topicID); got != pending {
|
|
t.Fatalf("pre-marker lookup = %q, want newest pending %q", got, pending)
|
|
}
|
|
if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := findTopicSession(dir, topicID); got != normal {
|
|
t.Fatalf("lookup with cleanup-pending newest = %q, want normal %q", got, normal)
|
|
}
|
|
if err := agent.MarkCleanupPending(normal, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := findTopicSession(dir, topicID); got != "" {
|
|
t.Fatalf("lookup with only cleanup-pending sessions = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenProjectTabSkipsCleanupPendingTopicSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "Pending topic")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending := writeTopicSessionWithPrompt(t, dir, "pending-topic.jsonl", topic.ID, "Pending topic", projectRoot, "pending topic prompt", time.Now())
|
|
if err := agent.MarkCleanupPending(pending, "delete"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := findTopicSession(dir, topic.ID); got != "" {
|
|
t.Fatalf("topic lookup with only cleanup-pending session = %q, want empty", got)
|
|
}
|
|
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != "" {
|
|
t.Fatalf("target topic lookup with only cleanup-pending session = %q, want empty", got)
|
|
}
|
|
|
|
meta, err := app.OpenProjectTab(projectRoot, topic.ID)
|
|
if err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
tab := waitForTabReady(t, app, meta.ID)
|
|
if got := filepath.Clean(tab.Ctrl.SessionPath()); got == filepath.Clean(pending) {
|
|
t.Fatalf("opened cleanup-pending topic session path %q", got)
|
|
}
|
|
for _, msg := range tab.Ctrl.History() {
|
|
if msg.Content == "pending topic prompt" {
|
|
t.Fatalf("opened cleanup-pending topic history at path %q: %+v", tab.Ctrl.SessionPath(), tab.Ctrl.History())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateTopicSessionTitlesUsesTopicIndex(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
topicID := "topic_title_index"
|
|
now := time.Now().UTC()
|
|
valid := writeTopicSessionWithPrompt(t, dir, "valid.jsonl", topicID, "Old", "", "hello", now)
|
|
unpreviewable := filepath.Join(dir, "unpreviewable.jsonl")
|
|
if err := os.WriteFile(unpreviewable, []byte("not-json\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := agent.SaveBranchMetaPreserveUpdated(unpreviewable, agent.BranchMeta{
|
|
CreatedAt: now.Add(-time.Minute),
|
|
UpdatedAt: now,
|
|
Scope: "global",
|
|
TopicID: topicID,
|
|
TopicTitle: "Old",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
NewApp().updateTopicSessionTitles(topicID, "Renamed")
|
|
|
|
for _, path := range []string{valid, unpreviewable} {
|
|
meta, ok, err := agent.LoadBranchMeta(path)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load meta for %s: ok=%v err=%v", path, ok, err)
|
|
}
|
|
if meta.TopicTitle != "Renamed" {
|
|
t.Fatalf("topic title for %s = %q, want Renamed", path, meta.TopicTitle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnsureTopicIndexedConcurrentRunsHaveNoLostProjectUpdates(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
projectRoot := t.TempDir()
|
|
const n = 12
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
i := i
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
topicID := fmt.Sprintf("topic_recovered_%02d", i)
|
|
if err := ensureTopicIndexed("project", projectRoot, topicID, fmt.Sprintf("Recovered %02d", i), topicTitleSourceManual); err != nil {
|
|
t.Errorf("ensure topic indexed: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
nodes := NewApp().ListProjectTree()
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("project tree len = %d, want 1: %#v", len(nodes), nodes)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, child := range nodes[0].Children {
|
|
got[child.TopicID] = true
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
topicID := fmt.Sprintf("topic_recovered_%02d", i)
|
|
if !got[topicID] {
|
|
t.Fatalf("concurrent topic index recovery lost %q; children=%#v", topicID, nodes[0].Children)
|
|
}
|
|
if title := loadTopicTitle(projectRoot, topicID); title == "" {
|
|
t.Fatalf("title index missing %q", topicID)
|
|
}
|
|
}
|
|
}
|