Files
DeepSeek-Reasonix/desktop/app_session_dedup_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

799 lines
26 KiB
Go

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