返回 DeepSeek-Reasonix
pinned_concurrency_test.go
根目录 / desktop / pinned_concurrency_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/store"
17 "reasonix/internal/tool"
18 )
19
20 type blockingPinnedProvider struct {
21 started chan struct{}
22 release chan struct{}
23 cancelled chan struct{}
24 once sync.Once
25 cancelOnce sync.Once
26 }
27
28 func (p *blockingPinnedProvider) Name() string { return "blocking-pinned" }
29
30 func (p *blockingPinnedProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
31 p.once.Do(func() { close(p.started) })
32 if p.cancelled == nil {
33 <-p.release
34 } else {
35 select {
36 case <-p.release:
37 case <-ctx.Done():
38 p.cancelOnce.Do(func() { close(p.cancelled) })
39 <-p.release
40 }
41 }
42 ch := make(chan provider.Chunk, 2)
43 ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
44 ch <- provider.Chunk{Type: provider.ChunkDone}
45 close(ch)
46 return ch, nil
47 }
48
49 func pinnedConcurrencyFixture(t *testing.T, prov provider.Provider) (*App, *WorkspaceTab, *control.Controller, string) {
50 t.Helper()
51 root := t.TempDir()
52 path := filepath.Join(root, "session.jsonl")
53 exec := agent.New(prov, tool.NewRegistry(), agent.NewSession("BASE"), agent.Options{}, event.Discard)
54 ctrl := control.New(control.Options{
55 Runner: exec,
56 Executor: exec,
57 SystemPrompt: "BASE",
58 PinnedContextLoader: pinnedContextLoader(root),
59 SessionDir: root,
60 SessionPath: path,
61 Label: "test",
62 Sink: event.Discard,
63 })
64 app := NewApp()
65 app.ctx = context.Background()
66 tab := &WorkspaceTab{
67 ID: "pinned-race",
68 Scope: "project",
69 WorkspaceRoot: root,
70 SessionPath: path,
71 Ready: true,
72 Ctrl: ctrl,
73 disabledMCP: map[string]ServerView{},
74 }
75 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
76 app.tabOrder = []string{tab.ID}
77 app.activeTabID = tab.ID
78 t.Cleanup(ctrl.Close)
79 return app, tab, ctrl, path
80 }
81
82 func TestPinAndUnpinRejectWhileTurnIsRunning(t *testing.T) {
83 prov := &blockingPinnedProvider{started: make(chan struct{}), release: make(chan struct{})}
84 app, tab, ctrl, path := pinnedConcurrencyFixture(t, prov)
85 if err := os.WriteFile(filepath.Join(tab.WorkspaceRoot, "context.md"), []byte("context"), 0o600); err != nil {
86 t.Fatal(err)
87 }
88 if _, err := app.PinFileForTab(tab.ID, "context.md"); err != nil {
89 t.Fatalf("initial pin: %v", err)
90 }
91 turnDone := make(chan error, 1)
92 go func() { turnDone <- ctrl.RunTurn(context.Background(), "hold") }()
93 select {
94 case <-prov.started:
95 case <-time.After(5 * time.Second):
96 t.Fatal("turn did not reach provider")
97 }
98
99 if err := app.UnpinFileForTab(tab.ID, "context.md"); err == nil {
100 t.Fatal("Unpin succeeded while a turn was running")
101 }
102 if _, err := app.PinFileForTab(tab.ID, "context.md"); err == nil {
103 t.Fatal("duplicate Pin succeeded while a turn was running")
104 }
105 state, err := loadPinnedContextState(path)
106 if err != nil {
107 t.Fatal(err)
108 }
109 if len(state.Files) != 1 || state.Files[0] != "context.md" {
110 t.Fatalf("busy mutations changed sidecar: %v", state.Files)
111 }
112 close(prov.release)
113 if err := <-turnDone; err != nil {
114 t.Fatalf("turn: %v", err)
115 }
116 }
117
118 func TestGetPinnedFilesForTabReturnsNonNilEmptyList(t *testing.T) {
119 app, tab, _, _ := pinnedConcurrencyFixture(t, nil)
120 infos, err := app.GetPinnedFilesForTab(tab.ID)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if infos == nil || len(infos) != 0 {
125 t.Fatalf("empty pinned files = %#v, want []", infos)
126 }
127 }
128
129 func TestGetPinnedFilesForTabDoesNotOverwriteNewerCachedPins(t *testing.T) {
130 app, tab, _, path := pinnedConcurrencyFixture(t, nil)
131 for _, name := range []string{"old.md", "new.md"} {
132 if err := os.WriteFile(filepath.Join(tab.WorkspaceRoot, name), []byte(name), 0o600); err != nil {
133 t.Fatal(err)
134 }
135 }
136 if err := savePinnedContextState(path, []string{"old.md"}); err != nil {
137 t.Fatal(err)
138 }
139
140 // Model the stale-read window: Get loaded the old sidecar while a newer
141 // Pin/Unpin or session binding already published the current tab cache.
142 tab.setPinnedFiles([]string{"new.md"})
143 infos, err := app.GetPinnedFilesForTab(tab.ID)
144 if err != nil {
145 t.Fatal(err)
146 }
147 if len(infos) != 1 || infos[0].Path != "old.md" {
148 t.Fatalf("GetPinnedFilesForTab = %#v, want old sidecar snapshot", infos)
149 }
150 if got := tab.GetPinnedFiles(); len(got) != 1 || got[0] != "new.md" {
151 t.Fatalf("read-only GetPinnedFilesForTab overwrote newer cache: %v", got)
152 }
153 }
154
155 func TestNewSessionWaitsForPinAndClearsItsResult(t *testing.T) {
156 isolateDesktopUserDirs(t)
157 oldRef, _ := configureSwitchableDefaultModels(t)
158 root := globalWorkspaceRoot()
159 if err := os.MkdirAll(root, 0o755); err != nil {
160 t.Fatal(err)
161 }
162 path, err := createEmptySessionFile(desktopSessionDir(root), "old-model")
163 if err != nil {
164 t.Fatal(err)
165 }
166 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
167 t.Fatal(err)
168 }
169 if err := os.WriteFile(filepath.Join(root, "context.md"), []byte("context"), 0o600); err != nil {
170 t.Fatal(err)
171 }
172 exec := agent.New(nil, nil, agent.NewSession("BASE"), agent.Options{}, event.Discard)
173 ctrl := control.New(control.Options{Executor: exec, SystemPrompt: "BASE", SessionDir: desktopSessionDir(root), SessionPath: path, Label: oldRef, Sink: event.Discard})
174 app := NewApp()
175 app.ctx = context.Background()
176 tab := &WorkspaceTab{
177 ID: "pin-new-race", Scope: "global", WorkspaceRoot: root, SessionPath: path,
178 Ready: true, Ctrl: ctrl, model: oldRef, disabledMCP: map[string]ServerView{},
179 }
180 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
181 app.tabOrder = []string{tab.ID}
182 app.activeTabID = tab.ID
183 t.Cleanup(func() {
184 if tab.Ctrl != nil {
185 tab.Ctrl.Close()
186 }
187 tab.releaseSessionLease()
188 })
189
190 readStarted := make(chan struct{})
191 releaseRead := make(chan struct{})
192 var once sync.Once
193 hook := func() {
194 once.Do(func() { close(readStarted) })
195 <-releaseRead
196 }
197 pinnedFileReadHookForTest.Store(&hook)
198 t.Cleanup(func() { pinnedFileReadHookForTest.Store(nil) })
199 pinDone := make(chan error, 1)
200 go func() {
201 _, err := app.PinFileForTab(tab.ID, "context.md")
202 pinDone <- err
203 }()
204 select {
205 case <-readStarted:
206 case <-time.After(5 * time.Second):
207 t.Fatal("Pin did not reach file read")
208 }
209 newBeforeLock := make(chan struct{})
210 var newBeforeLockOnce sync.Once
211 app.runtimeMutationBeforeLockHook = func(operation string) {
212 if operation == "new session" {
213 newBeforeLockOnce.Do(func() { close(newBeforeLock) })
214 }
215 }
216 newDone := make(chan error, 1)
217 go func() { newDone <- app.NewSessionForTab(tab.ID) }()
218 select {
219 case <-newBeforeLock:
220 case <-time.After(5 * time.Second):
221 t.Fatal("NewSession did not reach the runtime mutation barrier")
222 }
223 select {
224 case err := <-newDone:
225 t.Fatalf("NewSession bypassed in-flight Pin: %v", err)
226 default:
227 }
228 close(releaseRead)
229 if err := <-pinDone; err != nil {
230 t.Fatalf("PinFileForTab: %v", err)
231 }
232 if err := <-newDone; err != nil {
233 t.Fatalf("NewSessionForTab: %v", err)
234 }
235 state, err := loadPinnedContextState(tab.currentSessionPath())
236 if err != nil {
237 t.Fatal(err)
238 }
239 if len(state.Files) != 0 || len(tab.GetPinnedFiles()) != 0 {
240 t.Fatalf("new session inherited racing pin: sidecar=%v cache=%v", state.Files, tab.GetPinnedFiles())
241 }
242 if _, err := os.Stat(store.SessionPinnedContext(tab.currentSessionPath())); err != nil {
243 t.Fatalf("new session did not write an empty pinned sidecar: %v", err)
244 }
245 if got := tab.Ctrl.SystemPrompt(); got == "" || strings.Contains(got, "<pinned_context>") {
246 t.Fatalf("new controller retained pinned prompt: %q", got)
247 }
248 }
249
250 func TestRunningClearDoesNotMigrateOldPinnedCache(t *testing.T) {
251 isolateDesktopUserDirs(t)
252 oldRef, _ := configureSwitchableDefaultModels(t)
253 prov := &blockingPinnedProvider{started: make(chan struct{}), release: make(chan struct{}), cancelled: make(chan struct{})}
254 app, tab, ctrl, oldPath := pinnedConcurrencyFixture(t, prov)
255 t.Cleanup(func() {
256 if tab.Ctrl != nil && tab.Ctrl != ctrl {
257 tab.Ctrl.Close()
258 }
259 tab.releaseSessionLease()
260 })
261 tab.model = oldRef
262 if err := os.WriteFile(filepath.Join(tab.WorkspaceRoot, "context.md"), []byte("context"), 0o600); err != nil {
263 t.Fatal(err)
264 }
265 if _, err := app.PinFileForTab(tab.ID, "context.md"); err != nil {
266 t.Fatal(err)
267 }
268 go ctrl.Submit("hold")
269 select {
270 case <-prov.started:
271 case <-time.After(5 * time.Second):
272 t.Fatal("turn did not start")
273 }
274 clearDone := make(chan error, 1)
275 go func() {
276 _, err := app.ClearSessionForTab(tab.ID)
277 clearDone <- err
278 }()
279 select {
280 case <-prov.cancelled:
281 case <-time.After(5 * time.Second):
282 t.Fatal("clear did not cancel the running turn")
283 }
284 select {
285 case err := <-clearDone:
286 t.Fatalf("clear completed before the blocked provider stopped: %v", err)
287 default:
288 }
289 close(prov.release)
290 if err := <-clearDone; err != nil {
291 t.Fatalf("ClearSessionForTab: %v", err)
292 }
293 if _, err := os.Stat(store.SessionPinnedContext(oldPath)); !os.IsNotExist(err) {
294 t.Fatalf("old pinned sidecar survived clear: %v", err)
295 }
296 if got := tab.GetPinnedFiles(); len(got) != 0 {
297 t.Fatalf("running clear retained cached pins: %v", got)
298 }
299 state, err := loadPinnedContextState(tab.currentSessionPath())
300 if err != nil || len(state.Files) != 0 {
301 t.Fatalf("replacement pins = %+v, err=%v", state, err)
302 }
303 if _, err := os.Stat(store.SessionPinnedContext(tab.currentSessionPath())); err != nil {
304 t.Fatalf("running clear did not write an empty pinned sidecar: %v", err)
305 }
306 }
307
307 lines GO