返回 DeepSeek-Reasonix
tab_scoped_actions_test.go
根目录 / desktop / tab_scoped_actions_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/checkpoint"
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/provider"
14 )
15
16 type tabScopedActionController struct {
17 stubSessionAPI
18 history []provider.Message
19 newSessionCalls int
20 clearSessionCalls int
21 rewindCalls int
22 compactCalls int
23 forkCalls int
24 summarizeFrom int
25 summarizeUpTo int
26 }
27
28 func newTabScopedActionController() *tabScopedActionController {
29 return &tabScopedActionController{history: []provider.Message{
30 {Role: provider.RoleSystem, Content: "system"},
31 {Role: provider.RoleUser, Content: "keep this tab distinct"},
32 }}
33 }
34
35 func (c *tabScopedActionController) RuntimeStatus() control.RuntimeStatus {
36 return control.RuntimeStatus{}
37 }
38 func (c *tabScopedActionController) PlanMode() bool { return false }
39 func (c *tabScopedActionController) AutoApproveTools() bool { return false }
40 func (c *tabScopedActionController) Goal() string { return "" }
41 func (c *tabScopedActionController) ToolApprovalMode() string {
42 return control.ToolApprovalAsk
43 }
44 func (c *tabScopedActionController) History() []provider.Message {
45 return append([]provider.Message(nil), c.history...)
46 }
47 func (c *tabScopedActionController) WorkspaceRoot() string { return "" }
48 func (c *tabScopedActionController) SessionDir() string { return "" }
49 func (c *tabScopedActionController) SessionPath() string { return "" }
50 func (c *tabScopedActionController) Snapshot() error { return nil }
51 func (c *tabScopedActionController) SetDisplayRecorder(func(content, display string)) {
52 }
53 func (c *tabScopedActionController) NewSession() error {
54 c.newSessionCalls++
55 c.history = []provider.Message{{Role: provider.RoleSystem, Content: "system"}}
56 return nil
57 }
58 func (c *tabScopedActionController) ClearSession() error {
59 c.clearSessionCalls++
60 c.history = []provider.Message{{Role: provider.RoleSystem, Content: "system"}}
61 return nil
62 }
63 func (c *tabScopedActionController) Rewind(_ int, _ control.RewindScope) error {
64 c.rewindCalls++
65 return nil
66 }
67 func (c *tabScopedActionController) PrepareRewind(_ int, _ control.RewindScope) (checkpoint.RewindPlan, error) {
68 return checkpoint.RewindPlan{PlanID: "tab-scoped", CanFiles: true, CanConversation: true}, nil
69 }
70 func (c *tabScopedActionController) CommitRewind(_ string) (checkpoint.RewindResult, error) {
71 c.rewindCalls++
72 return checkpoint.RewindResult{OK: true}, nil
73 }
74 func (c *tabScopedActionController) Compact(_ context.Context, _ string) error {
75 c.compactCalls++
76 return nil
77 }
78 func (c *tabScopedActionController) ForkSession(_ int, _ string) (string, error) {
79 c.forkCalls++
80 return "", errors.New("stop after selecting source controller")
81 }
82 func (c *tabScopedActionController) SummarizeFrom(_ context.Context, _ int) error {
83 c.summarizeFrom++
84 return nil
85 }
86 func (c *tabScopedActionController) SummarizeUpTo(_ context.Context, _ int) error {
87 c.summarizeUpTo++
88 return nil
89 }
90
91 type blockingForkTabController struct {
92 *tabScopedActionController
93 path string
94 started chan struct{}
95 release chan struct{}
96 }
97
98 func (c *blockingForkTabController) ForkSession(_ int, _ string) (string, error) {
99 close(c.started)
100 <-c.release
101 return c.path, nil
102 }
103
104 func TestTabScopedSessionActionsIgnoreFocusedTab(t *testing.T) {
105 isolateDesktopUserDirs(t)
106 targetCtrl := newTabScopedActionController()
107 focusedCtrl := newTabScopedActionController()
108 app := &App{
109 tabs: map[string]*WorkspaceTab{
110 "target": {ID: "target", Scope: "global", Ready: true, Ctrl: targetCtrl, disabledMCP: map[string]ServerView{}},
111 "focused": {ID: "focused", Scope: "global", Ready: true, Ctrl: focusedCtrl, disabledMCP: map[string]ServerView{}},
112 },
113 tabOrder: []string{"target", "focused"},
114 activeTabID: "focused",
115 }
116
117 if err := app.NewSessionForTab("target"); err != nil {
118 t.Fatalf("NewSessionForTab: %v", err)
119 }
120 // Restore content so ClearSession exercises its non-blank path too.
121 targetCtrl.history = []provider.Message{{Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "clear me"}}
122 if _, err := app.ClearSessionForTab("target"); err != nil {
123 t.Fatalf("ClearSessionForTab: %v", err)
124 }
125 if err := app.RewindForTab("target", 1, "conversation"); err != nil {
126 t.Fatalf("RewindForTab: %v", err)
127 }
128 if err := app.CompactForTab("target"); err != nil {
129 t.Fatalf("CompactForTab: %v", err)
130 }
131 if _, err := app.ForkForTab("target", 1); err == nil || err.Error() != "stop after selecting source controller" {
132 t.Fatalf("ForkForTab error = %v, want source-controller sentinel", err)
133 }
134 if err := app.SummarizeFromForTab("target", 1); err != nil {
135 t.Fatalf("SummarizeFromForTab: %v", err)
136 }
137 if err := app.SummarizeUpToForTab("target", 1); err != nil {
138 t.Fatalf("SummarizeUpToForTab: %v", err)
139 }
140
141 if targetCtrl.newSessionCalls != 1 || targetCtrl.clearSessionCalls != 1 || targetCtrl.rewindCalls != 1 || targetCtrl.compactCalls != 1 || targetCtrl.forkCalls != 1 || targetCtrl.summarizeFrom != 1 || targetCtrl.summarizeUpTo != 1 {
142 t.Fatalf("target calls = new:%d clear:%d rewind:%d compact:%d fork:%d from:%d upto:%d, want all 1",
143 targetCtrl.newSessionCalls, targetCtrl.clearSessionCalls, targetCtrl.rewindCalls, targetCtrl.compactCalls, targetCtrl.forkCalls, targetCtrl.summarizeFrom, targetCtrl.summarizeUpTo)
144 }
145 if focusedCtrl.newSessionCalls != 0 || focusedCtrl.clearSessionCalls != 0 || focusedCtrl.rewindCalls != 0 || focusedCtrl.compactCalls != 0 || focusedCtrl.forkCalls != 0 || focusedCtrl.summarizeFrom != 0 || focusedCtrl.summarizeUpTo != 0 {
146 t.Fatalf("focused tab received scoped action: %+v", focusedCtrl)
147 }
148 if app.activeTabID != "focused" {
149 t.Fatalf("active tab = %q, want focused", app.activeTabID)
150 }
151 }
152
153 func TestForkForTabDoesNotOverrideLaterActiveTab(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 ctrl := &blockingForkTabController{
156 tabScopedActionController: newTabScopedActionController(),
157 path: filepath.Join(config.SessionDir(), "fork.jsonl"),
158 started: make(chan struct{}),
159 release: make(chan struct{}),
160 }
161 // Only the persistence-to-publish interleaving matters here; fork itself
162 // need not run in another goroutine or race a wall-clock deadline.
163 close(ctrl.release)
164 app := NewApp()
165 app.ctx = context.Background()
166 app.setTestCtrl(ctrl, "")
167 app.tabs["test"].TopicTitle = "Source topic"
168 app.tabs["other"] = &WorkspaceTab{ID: "other", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
169 app.tabOrder = []string{"test", "other"}
170
171 // Match the app's asynchronous build path, but cancel and join the build
172 // before it acquires controllers or session leases unrelated to this test.
173 buildRelease := make(chan struct{})
174 app.tabBuildStartHook = func(string) { <-buildRelease }
175 t.Cleanup(func() {
176 app.mu.Lock()
177 var builds []<-chan struct{}
178 for _, tab := range app.tabs {
179 if tab.buildDone != nil {
180 builds = append(builds, tab.buildDone)
181 }
182 app.supersedeTabBuildLocked(tab)
183 }
184 app.mu.Unlock()
185 close(buildRelease)
186 for _, done := range builds {
187 if !waitChannelBefore(done, time.Now().Add(5*time.Second)) {
188 t.Error("fork controller build did not exit after cancellation")
189 }
190 }
191 })
192 hook := func() {
193 if err := app.SetActiveTab("other"); err != nil {
194 t.Fatalf("SetActiveTab(other): %v", err)
195 }
196 }
197 forkTabBeforePublishHookForTest.Store(&hook)
198 t.Cleanup(func() { forkTabBeforePublishHookForTest.Store(nil) })
199
200 meta, err := app.ForkForTab("test", 1)
201 if err != nil {
202 t.Fatalf("ForkForTab: %v", err)
203 }
204 if meta.ID == "" || meta.ID == "test" {
205 t.Fatalf("fork tab ID = %q, want a fresh tab", meta.ID)
206 }
207 if meta.Active {
208 t.Fatalf("fork meta active = true, want false after later tab switch")
209 }
210 if app.activeTabID != "other" {
211 t.Fatalf("active tab = %q, want later selection %q", app.activeTabID, "other")
212 }
213 }
214
214 lines GO