返回 DeepSeek-Reasonix
session_prompt_test.go
根目录 / desktop / session_prompt_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool"
18 )
19
20 type promptResumeCtrl struct {
21 history []provider.Message
22 resumed *agent.Session
23 path string
24 }
25
26 func (c *promptResumeCtrl) History() []provider.Message {
27 return append([]provider.Message(nil), c.history...)
28 }
29
30 func (c *promptResumeCtrl) Resume(s *agent.Session, path string) {
31 c.resumed = s
32 c.path = path
33 }
34
35 func (c *promptResumeCtrl) SetSessionPath(path string) {
36 c.path = path
37 }
38
39 func TestSessionWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
40 path := filepath.Join(t.TempDir(), "session.jsonl")
41 s := agent.NewSession("old sys")
42 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
43 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
44 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
45 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
46 if err := s.Save(path); err != nil {
47 t.Fatalf("Save base: %v", err)
48 }
49
50 loaded, err := agent.LoadSession(path)
51 if err != nil {
52 t.Fatalf("LoadSession: %v", err)
53 }
54 resumed := sessionWithFreshSystemPrompt(loaded, "new sys")
55 if reasons := resumed.DrainContentRewriteReasons(); len(reasons) != 1 || reasons[0] != "legacy_pinned_system_migration" {
56 t.Fatalf("content rewrite reasons = %v, want legacy migration", reasons)
57 }
58 msgs := resumed.Snapshot()
59 msgs[3].Content = "[elided tool result]"
60 resumed.Replace(msgs)
61 if err := resumed.SaveRewrite(path); err != nil {
62 t.Fatalf("SaveRewrite fresh-system resume: %v", err)
63 }
64
65 reloaded, err := agent.LoadSession(path)
66 if err != nil {
67 t.Fatalf("LoadSession rewritten: %v", err)
68 }
69 if got := reloaded.Messages[0].Content; got != "new sys" {
70 t.Fatalf("system prompt after rewrite = %q, want new sys", got)
71 }
72 if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
73 t.Fatalf("tool result after rewrite = %q, want elided", got)
74 }
75 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
76 t.Fatalf("recovery branches after owned resume rewrite = %v err=%v, want none", matches, err)
77 }
78 }
79
80 func TestMigratedSessionWithoutSystemPromptPersistsThroughDesktopSwitch(t *testing.T) {
81 isolateDesktopUserDirs(t)
82 src := t.TempDir()
83 dest := t.TempDir()
84 const legacy = `{"role":"user","content":"recovered after downgrade"}
85 {"role":"assistant","content":"legacy answer"}
86 `
87 if err := os.WriteFile(filepath.Join(src, "desktop-legacy.jsonl"), []byte(legacy), 0o644); err != nil {
88 t.Fatalf("write legacy session: %v", err)
89 }
90 if n, err := agent.MigrateLegacySessions(src, dest, nil); err != nil || n != 1 {
91 t.Fatalf("MigrateLegacySessions: n=%d err=%v", n, err)
92 }
93
94 path := filepath.Join(dest, "desktop-legacy.jsonl")
95 loaded, err := agent.LoadSession(path)
96 if err != nil {
97 t.Fatalf("LoadSession migrated: %v", err)
98 }
99 const freshSystem = "current deterministic system prompt"
100 prov := &capturingProvider{}
101 exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(freshSystem), agent.Options{}, event.Discard)
102 ctrl := control.New(control.Options{
103 Runner: exec,
104 Executor: exec,
105 SystemPrompt: freshSystem,
106 SessionDir: dest,
107 SessionPath: path,
108 Label: "migrated",
109 Sink: event.Discard,
110 })
111 defer ctrl.Close()
112 resumeLoadedSessionAndGoal(ctrl, loaded, path, "")
113
114 if history := ctrl.History(); len(history) == 0 || history[0].Role != provider.RoleSystem || history[0].Content != freshSystem {
115 t.Fatalf("resumed history does not start with the fresh system prompt: %+v", history)
116 }
117 if err := ctrl.RunTurn(context.Background(), "new desktop turn"); err != nil {
118 t.Fatalf("RunTurn: %v", err)
119 }
120
121 active := &WorkspaceTab{
122 ID: "legacy",
123 Ctrl: ctrl,
124 Scope: "global",
125 SessionPath: path,
126 Ready: true,
127 disabledMCP: map[string]ServerView{},
128 }
129 target := &WorkspaceTab{
130 ID: "target",
131 Scope: "global",
132 Ready: true,
133 disabledMCP: map[string]ServerView{},
134 }
135 app := &App{
136 tabs: map[string]*WorkspaceTab{"legacy": active, "target": target},
137 tabOrder: []string{"legacy", "target"},
138 activeTabID: "legacy",
139 }
140 if err := app.SetActiveTab("target"); err != nil {
141 t.Fatalf("SetActiveTab: %v", err)
142 }
143
144 reloaded, err := agent.LoadSession(path)
145 if err != nil {
146 t.Fatalf("LoadSession after switch: %v", err)
147 }
148 got := reloaded.Snapshot()
149 if len(got) != 5 {
150 t.Fatalf("reloaded message count = %d, want 5: %+v", len(got), got)
151 }
152 if got[0].Role != provider.RoleSystem || got[0].Content != freshSystem {
153 t.Fatalf("reloaded system prompt = %+v, want %q", got[0], freshSystem)
154 }
155 if got[3].Role != provider.RoleUser || agent.StripTransientUserBlocks(got[3].Content) != "new desktop turn" {
156 t.Fatalf("reloaded new user turn = %+v", got[3])
157 }
158 if got[4].Role != provider.RoleAssistant || got[4].Content != "ok" {
159 t.Fatalf("reloaded assistant turn = %+v", got[4])
160 }
161 if matches, err := filepath.Glob(filepath.Join(dest, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
162 t.Fatalf("recovery branches after migrated switch = %v err=%v, want none", matches, err)
163 }
164 }
165
166 func TestParallelDesktopTabsPersistCompleteTranscriptsAcrossReload(t *testing.T) {
167 isolateDesktopUserDirs(t)
168 const (
169 tabCount = 12
170 turnsPerTab = 6
171 systemText = "stable desktop system prompt"
172 )
173 dir := t.TempDir()
174 tabs := make(map[string]*WorkspaceTab, tabCount)
175 tabOrder := make([]string, 0, tabCount)
176 controllers := make([]*control.Controller, 0, tabCount)
177
178 for tabIndex := range tabCount {
179 id := fmt.Sprintf("parallel-%02d", tabIndex)
180 path := filepath.Join(dir, id+".jsonl")
181 prov := &capturingProvider{}
182 exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(systemText), agent.Options{}, event.Discard)
183 ctrl := control.New(control.Options{
184 Runner: exec,
185 Executor: exec,
186 SystemPrompt: systemText,
187 SessionDir: dir,
188 SessionPath: path,
189 Label: id,
190 Sink: event.Discard,
191 })
192 controllers = append(controllers, ctrl)
193 tabOrder = append(tabOrder, id)
194 tabs[id] = &WorkspaceTab{
195 ID: id,
196 Ctrl: ctrl,
197 Scope: "global",
198 SessionPath: path,
199 Ready: true,
200 disabledMCP: map[string]ServerView{},
201 }
202 }
203 app := &App{
204 tabs: tabs,
205 tabOrder: tabOrder,
206 activeTabID: tabOrder[0],
207 }
208
209 start := make(chan struct{})
210 errs := make(chan error, tabCount+1)
211 var wg sync.WaitGroup
212 for tabIndex, ctrl := range controllers {
213 wg.Go(func() {
214 <-start
215 for turn := range turnsPerTab {
216 input := fmt.Sprintf("tab-%02d-turn-%02d", tabIndex, turn)
217 if err := ctrl.RunTurn(context.Background(), input); err != nil {
218 errs <- fmt.Errorf("%s: %w", input, err)
219 return
220 }
221 }
222 })
223 }
224 wg.Go(func() {
225 <-start
226 for range 3 {
227 for _, id := range tabOrder {
228 if err := app.SetActiveTab(id); err != nil {
229 errs <- fmt.Errorf("switch to %s: %w", id, err)
230 return
231 }
232 }
233 }
234 })
235
236 close(start)
237 wg.Wait()
238 close(errs)
239 for err := range errs {
240 t.Error(err)
241 }
242 if t.Failed() {
243 for _, ctrl := range controllers {
244 ctrl.Close()
245 }
246 return
247 }
248
249 for _, ctrl := range controllers {
250 if err := ctrl.Snapshot(); err != nil {
251 t.Fatalf("final snapshot %s: %v", ctrl.Label(), err)
252 }
253 ctrl.Close()
254 }
255
256 for tabIndex, id := range tabOrder {
257 path := tabs[id].SessionPath
258 reloaded, err := agent.LoadSession(path)
259 if err != nil {
260 t.Fatalf("LoadSession %s: %v", id, err)
261 }
262 msgs := reloaded.Snapshot()
263 wantMessages := 1 + turnsPerTab*2
264 if len(msgs) != wantMessages {
265 t.Fatalf("%s message count = %d, want %d: %+v", id, len(msgs), wantMessages, msgs)
266 }
267 if msgs[0].Role != provider.RoleSystem || msgs[0].Content != systemText {
268 t.Fatalf("%s system message = %+v", id, msgs[0])
269 }
270 for turn := range turnsPerTab {
271 user := msgs[1+turn*2]
272 assistant := msgs[2+turn*2]
273 wantUser := fmt.Sprintf("tab-%02d-turn-%02d", tabIndex, turn)
274 if user.Role != provider.RoleUser || agent.StripTransientUserBlocks(user.Content) != wantUser {
275 t.Fatalf("%s turn %d user = %+v, want %q", id, turn, user, wantUser)
276 }
277 if assistant.Role != provider.RoleAssistant || assistant.Content != "ok" {
278 t.Fatalf("%s turn %d assistant = %+v, want ok", id, turn, assistant)
279 }
280 }
281 }
282 }
283
284 func TestResumeWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
285 path := filepath.Join(t.TempDir(), "session.jsonl")
286 s := agent.NewSession("old sys")
287 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
288 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
289 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
290 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
291 if err := s.Save(path); err != nil {
292 t.Fatalf("Save base: %v", err)
293 }
294
295 loaded, err := agent.LoadSession(path)
296 if err != nil {
297 t.Fatalf("LoadSession: %v", err)
298 }
299 ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
300 resumeWithFreshSystemPrompt(ctrl, loaded.Snapshot(), path)
301 if ctrl.resumed == nil {
302 t.Fatalf("Resume was not called")
303 }
304 if reasons := ctrl.resumed.DrainContentRewriteReasons(); len(reasons) != 1 || reasons[0] != "legacy_pinned_system_migration" {
305 t.Fatalf("content rewrite reasons = %v, want legacy migration", reasons)
306 }
307
308 msgs := ctrl.resumed.Snapshot()
309 msgs[3].Content = "[elided tool result]"
310 ctrl.resumed.Replace(msgs)
311 if err := ctrl.resumed.SaveRewrite(path); err != nil {
312 t.Fatalf("SaveRewrite resumed history: %v", err)
313 }
314
315 if got := ctrl.path; got != path {
316 t.Fatalf("resume path = %q, want %q", got, path)
317 }
318 reloaded, err := agent.LoadSession(path)
319 if err != nil {
320 t.Fatalf("LoadSession rewritten: %v", err)
321 }
322 if got := reloaded.Messages[0].Content; got != "new sys" {
323 t.Fatalf("system prompt after rewrite = %q, want new sys", got)
324 }
325 if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
326 t.Fatalf("tool result after rewrite = %q, want elided", got)
327 }
328 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
329 t.Fatalf("recovery branches after resume rewrite = %v err=%v, want none", matches, err)
330 }
331 }
332
333 func TestResumeWithFreshSystemPromptRejectsStaleCarriedHistoryBaseline(t *testing.T) {
334 t.Setenv(agent.SessionLogSchemaEnv, "v1")
335 path := filepath.Join(t.TempDir(), "session.jsonl")
336 current := agent.NewSession("old sys")
337 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
338 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
339 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
340 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
341 if err := current.Save(path); err != nil {
342 t.Fatalf("Save current: %v", err)
343 }
344
345 stale := []provider.Message{
346 {Role: provider.RoleSystem, Content: "old sys"},
347 {Role: provider.RoleUser, Content: "first"},
348 {Role: provider.RoleAssistant, Content: "one"},
349 }
350 ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
351 resumeWithFreshSystemPrompt(ctrl, stale, path)
352 if ctrl.resumed == nil {
353 t.Fatalf("Resume was not called")
354 }
355 if err := ctrl.resumed.SaveRewrite(path); !errors.Is(err, agent.ErrSessionSnapshotConflict) {
356 t.Fatalf("SaveRewrite stale carried history err = %v, want ErrSessionSnapshotConflict", err)
357 }
358
359 reloaded, err := agent.LoadSession(path)
360 if err != nil {
361 t.Fatalf("LoadSession current: %v", err)
362 }
363 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
364 t.Fatalf("original tail after stale resume rewrite = %q, want disk two", got)
365 }
366 }
367
367 lines GO