返回 DeepSeek-Reasonix
history_test.go
根目录 / desktop / history_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12 "unicode/utf8"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/boot"
16 "reasonix/internal/control"
17 "reasonix/internal/event"
18 "reasonix/internal/provider"
19 "reasonix/internal/store"
20 "reasonix/internal/tool"
21 "reasonix/internal/transcript"
22 )
23
24 func TestHistoryMessagesIncludeAssistantReasoning(t *testing.T) {
25 msgs := []provider.Message{
26 {Role: provider.RoleUser, Content: "expanded prompt", CreatedAt: 1_718_000_000_000},
27 {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "thinking trace", WorkDurationMs: 24_000, ToolCalls: []provider.ToolCall{{
28 ID: "call_1", Name: "bash", Arguments: `{"command":"pwd"}`,
29 }}, MemoryCitations: []provider.MemoryCitation{{
30 ID: "mem-1", Source: "Memory v5", Note: "use previous bash failure", Kind: "constraint",
31 }}},
32 {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "tool output", ReasoningContent: "ignored by frontend filter"},
33 {Role: provider.RoleAssistant, ReasoningContent: "tool-call-only thinking"},
34 }
35
36 got := historyMessages(msgs, func(content string) string {
37 if content != "expanded prompt" {
38 t.Fatalf("unexpected user content passed to resolver: %q", content)
39 }
40 return "display prompt"
41 })
42
43 if len(got) != len(msgs) {
44 t.Fatalf("history length = %d, want %d", len(got), len(msgs))
45 }
46 if got[0].Content != "display prompt" {
47 t.Fatalf("user display content = %q, want display prompt", got[0].Content)
48 }
49 if got[0].SubmitText != "expanded prompt" {
50 t.Fatalf("user submit text = %q, want expanded prompt", got[0].SubmitText)
51 }
52 if got[0].CreatedAt != 1_718_000_000_000 {
53 t.Fatalf("user createdAt = %d, want 1718000000000", got[0].CreatedAt)
54 }
55 if got[1].Reasoning != "thinking trace" {
56 t.Fatalf("assistant reasoning = %q, want thinking trace", got[1].Reasoning)
57 }
58 if got[1].WorkDurationMs != 24_000 {
59 t.Fatalf("assistant work duration = %d, want 24000", got[1].WorkDurationMs)
60 }
61 if len(got[1].MemoryCitations) != 1 || got[1].MemoryCitations[0].Note != "use previous bash failure" {
62 t.Fatalf("assistant memory citations not preserved: %+v", got[1].MemoryCitations)
63 }
64 if len(got[1].ToolCalls) != 1 || got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "bash" {
65 t.Fatalf("assistant tool calls not preserved: %+v", got[1].ToolCalls)
66 }
67 if !got[1].ToolCalls[0].ArgumentsArchived || got[1].ToolCalls[0].Arguments != "" || got[1].ToolCalls[0].Subject != "pwd" {
68 t.Fatalf("assistant tool call was not restored as lightweight metadata: %+v", got[1].ToolCalls[0])
69 }
70 if got[2].ToolCallID != "call_1" || got[2].ToolName != "bash" || got[2].Content != "" || !got[2].ToolResultArchived {
71 t.Fatalf("tool result details not preserved: %+v", got[2])
72 }
73 if got[2].Reasoning != "" {
74 t.Fatalf("non-assistant reasoning should stay hidden, got %q", got[2].Reasoning)
75 }
76 if got[3].Reasoning != "tool-call-only thinking" {
77 t.Fatalf("empty-content assistant reasoning = %q, want tool-call-only thinking", got[3].Reasoning)
78 }
79 }
80
81 func TestHistoryMessagesReplayAttachedDecisionReceiptAfterAssistant(t *testing.T) {
82 receipt := &provider.DecisionReceipt{ID: "approval-1", Kind: "tool", Tool: "bash", Outcome: "allow_once"}
83 got := historyMessages([]provider.Message{
84 {Role: provider.RoleUser, Content: "run it"},
85 {
86 Role: provider.RoleAssistant,
87 ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "bash", Arguments: `{}`}},
88 DecisionReceipts: []*provider.DecisionReceipt{receipt},
89 },
90 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "bash", Content: "ok"},
91 }, func(content string) string { return content })
92
93 if len(got) != 4 {
94 t.Fatalf("history messages = %d, want user, assistant, receipt, tool: %+v", len(got), got)
95 }
96 if len(got[1].ToolCalls) != 1 || got[2].Code != event.NoticeCodeDecisionReceipt || got[2].DecisionReceipt == nil {
97 t.Fatalf("history did not replay the decision after its assistant call: %+v", got)
98 }
99 if got[3].Role != "tool" || got[3].ToolCallID != "call-1" || !got[3].ToolResultArchived {
100 t.Fatalf("history lost the actual tool result: %+v", got[3])
101 }
102 }
103
104 func TestHistoryMessagesPreferPersistedRawUserContent(t *testing.T) {
105 const raw = "fix the bug"
106 const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\nfix the bug"
107 msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: raw}}
108
109 got := historyMessages(msgs, historyReplayUserContent)
110 if len(got) != 1 || got[0].Content != raw {
111 t.Fatalf("history user content = %+v, want raw %q", got, raw)
112 }
113 if got[0].SubmitText != "" {
114 t.Fatalf("provider-only wrapper should not become replay text, got %q", got[0].SubmitText)
115 }
116 if strings.Contains(got[0].Content, "capability-route") || strings.Contains(got[0].SubmitText, "capability-route") {
117 t.Fatalf("provider-only wrapper leaked into history: %+v", got[0])
118 }
119 }
120
121 func TestHistoryMessagesRecoverLegacyExpandedPasteWithoutSidecar(t *testing.T) {
122 const label = "[已粘贴文本 #1 · 3 行]"
123 const display = "review this\n\n" + label
124 const expanded = display + "\n\n--- Begin " + label + " ---\nfirst\nsecond\nthird\n--- End " + label + " ---"
125 const rendered = "<active-goal>\nship the release\n</active-goal>\n\n" + expanded
126 msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: expanded}}
127
128 got := historyMessages(msgs, historyReplayUserContent)
129 if len(got) != 1 || got[0].Content != display {
130 t.Fatalf("legacy pasted display = %+v, want %q", got, display)
131 }
132 if got[0].SubmitText != expanded {
133 t.Fatalf("legacy pasted replay = %q, want expanded user input", got[0].SubmitText)
134 }
135 if strings.Contains(got[0].SubmitText, "<active-goal>") {
136 t.Fatalf("transient goal leaked into legacy replay: %+v", got[0])
137 }
138 }
139
140 func TestHistoryMessagesExpandedRawSupportsSidecarAndPreviousClients(t *testing.T) {
141 const label = "[Pasted text #1 · 2 lines]"
142 const display = "inspect\n\n" + label
143 const expanded = display + "\n\n--- Begin " + label + " ---\none\ntwo\n--- End " + label + " ---"
144 const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\n" + expanded
145 msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: expanded}}
146
147 // Previous desktop releases use RawContent as their replay source. Keeping
148 // the expanded markers there lets them reconstruct the same inline card
149 // instead of rendering an opaque label with no accessible payload.
150 previousReplay := agent.UserMessageText(msgs[0])
151 if !strings.Contains(previousReplay, "--- Begin "+label+" ---") || !strings.Contains(previousReplay, "--- End "+label+" ---") {
152 t.Fatalf("previous-client replay lost pasted payload markers: %q", previousReplay)
153 }
154
155 got := historyMessages(msgs, func(content string) string {
156 if content != rendered {
157 t.Fatalf("sidecar resolver content = %q, want rendered content", content)
158 }
159 return display
160 })
161 if len(got) != 1 || got[0].Content != display || got[0].SubmitText != expanded {
162 t.Fatalf("legacy sidecar history = %+v, want display %q and expanded replay", got, display)
163 }
164 if strings.Contains(got[0].SubmitText, "capability-route") {
165 t.Fatalf("provider-only wrapper leaked into sidecar replay: %+v", got[0])
166 }
167 }
168
169 func TestHistoryMessagesLegacyReferenceReplayExcludesResolvedContext(t *testing.T) {
170 const raw = "@src/main.go explain the entrypoint"
171 const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\nReferenced context:\n\n<file path=\"src/main.go\">\npackage main\n</file>\n\n" + raw
172 msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: raw}}
173
174 got := historyMessages(msgs, historyReplayUserContent)
175 if len(got) != 1 || got[0].Content != raw || got[0].SubmitText != "" {
176 t.Fatalf("legacy reference history = %+v, want compact raw replay", got)
177 }
178 if strings.Contains(got[0].Content, "<file") || strings.Contains(got[0].SubmitText, "<file") {
179 t.Fatalf("resolved reference leaked into editable history: %+v", got[0])
180 }
181 }
182
183 func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
184 raw := historyMemoryCompilerContract(t, "ship the refactor")
185 msgs := []provider.Message{
186 {Role: provider.RoleUser, Content: raw},
187 {Role: provider.RoleAssistant, Content: "done"},
188 }
189
190 got := historyMessages(msgs, control.StripComposePrefixes)
191 if len(got) != 2 {
192 t.Fatalf("history length = %d, want 2: %+v", len(got), got)
193 }
194 if got[0].Content != "ship the refactor" {
195 t.Fatalf("visible user content = %q, want source_event", got[0].Content)
196 }
197 if got[0].SubmitText != "" {
198 t.Fatalf("raw Memory v5 contract should not be replay submitText, got %q", got[0].SubmitText)
199 }
200 assertNoHistoryMemoryContract(t, got[0].Content)
201 }
202
203 func TestHistoryMessagesRestoreCompiledSkillInvocationWithoutContract(t *testing.T) {
204 raw := historyMemoryCompilerContract(t, "/reasonix-develop ship the refactor")
205 msgs := []provider.Message{{Role: provider.RoleUser, Content: raw}}
206
207 got := historyMessages(msgs, func(string) string { return "ship the refactor" })
208 if len(got) != 1 {
209 t.Fatalf("history length = %d, want 1: %+v", len(got), got)
210 }
211 if got[0].Content != "ship the refactor" || got[0].SubmitText != "/reasonix-develop ship the refactor" {
212 t.Fatalf("compiled skill history = %+v", got[0])
213 }
214 assertNoHistoryMemoryContract(t, got[0].Content)
215 assertNoHistoryMemoryContract(t, got[0].SubmitText)
216 }
217
218 func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
219 raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
220 msgs := []provider.Message{
221 {Role: provider.RoleUser, Content: raw},
222 {Role: provider.RoleAssistant, Content: "done"},
223 }
224
225 got := historyMessages(msgs, control.StripComposePrefixes)
226 if len(got) != 2 {
227 t.Fatalf("history length = %d, want 2: %+v", len(got), got)
228 }
229 if got[0].Content != "continue implementation" {
230 t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content)
231 }
232 if strings.Contains(got[0].Content, "<active-goal>") || strings.Contains(got[0].Content, "ship the approval redesign") {
233 t.Fatalf("active-goal leaked into visible history content: %+v", got[0])
234 }
235 }
236
237 func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) {
238 msgs := []provider.Message{
239 {Role: provider.RoleSystem, Content: "sys"},
240 {Role: provider.RoleUser, Content: "first visible"},
241 {Role: provider.RoleAssistant, Content: "first answer"},
242 {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."},
243 {Role: provider.RoleAssistant, Content: "hidden continuation"},
244 {Role: provider.RoleUser, Content: "second visible"},
245 {Role: provider.RoleAssistant, Content: "second answer"},
246 }
247
248 got := historyMessagesWithPlannerDisplays(
249 msgs,
250 func(content string) string { return content },
251 nil,
252 map[int]int{1: 0, 5: 2},
253 )
254 var users []HistoryMessage
255 for _, msg := range got {
256 if msg.Role == "user" {
257 users = append(users, msg)
258 }
259 }
260 if len(users) != 2 {
261 t.Fatalf("visible users = %d, want 2: %+v", len(users), got)
262 }
263 if users[0].CheckpointTurn == nil || *users[0].CheckpointTurn != 0 {
264 t.Fatalf("first checkpoint turn = %v, want 0", users[0].CheckpointTurn)
265 }
266 if users[1].CheckpointTurn == nil || *users[1].CheckpointTurn != 2 {
267 t.Fatalf("second checkpoint turn = %v, want 2", users[1].CheckpointTurn)
268 }
269 }
270
271 func TestHistoryPageFromMessagesWindowsByUserTurn(t *testing.T) {
272 messages := []HistoryMessage{
273 {Role: "notice", Content: "session restored"},
274 {Role: "user", Content: "first"},
275 {Role: "assistant", Content: "one"},
276 {Role: "tool", ToolName: "bash", Content: "tool one"},
277 {Role: "user", Content: "second"},
278 {Role: "assistant", Content: "two"},
279 {Role: "user", Content: "third"},
280 {Role: "assistant", Content: "three"},
281 }
282
283 latest := historyPageFromMessages(messages, 0, 2)
284 if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder {
285 t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest)
286 }
287 if len(latest.Messages) != 4 || latest.Messages[0].Content != "second" || latest.Messages[3].Content != "three" {
288 t.Fatalf("latest page messages = %+v, want second and third turns", latest.Messages)
289 }
290
291 older := historyPageFromMessages(messages, latest.StartTurn, 2)
292 if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder {
293 t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older)
294 }
295 if len(older.Messages) != 4 || older.Messages[0].Content != "session restored" || older.Messages[1].Content != "first" {
296 t.Fatalf("older page messages = %+v, want prelude and first turn", older.Messages)
297 }
298 }
299
300 func TestHistoryPageWithFingerprintBindsRevisionToExactContentDigest(t *testing.T) {
301 path := filepath.Join(t.TempDir(), "session.jsonl")
302 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
303 Revision: 7,
304 ContentDigest: "digest-v7",
305 }); err != nil {
306 t.Fatalf("save branch metadata: %v", err)
307 }
308
309 page := historyPageWithFingerprint(HistoryPage{Messages: []HistoryMessage{{Role: "user", Content: "hello"}}}, path, "digest-v7")
310 if page.Revision != 7 || page.Digest != "digest-v7" {
311 t.Fatalf("history fingerprint = revision %d digest %q, want revision 7 digest-v7", page.Revision, page.Digest)
312 }
313
314 stale := historyPageWithFingerprint(HistoryPage{Messages: []HistoryMessage{{Role: "user", Content: "older"}}}, path, "digest-v6")
315 if stale.Revision != 0 || stale.Digest != "digest-v6" {
316 t.Fatalf("stale page fingerprint = revision %d digest %q, want content digest without mismatched revision", stale.Revision, stale.Digest)
317 }
318 }
319
320 func TestHistoryPageFromProviderMessagesWindowsVisibleUsers(t *testing.T) {
321 msgs := []provider.Message{
322 {Role: provider.RoleSystem, Content: "sys"},
323 {Role: provider.RoleUser, Content: "first"},
324 {Role: provider.RoleAssistant, Content: "one"},
325 {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."},
326 {Role: provider.RoleAssistant, Content: "hidden continuation"},
327 {Role: provider.RoleUser, Content: "second"},
328 {Role: provider.RoleAssistant, Content: "two"},
329 {
330 Role: provider.RoleTool, Content: agent.MidTurnSteerPrefix + "\nupdate the plan",
331 ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true,
332 },
333 {Role: provider.RoleUser, Content: "third"},
334 {Role: provider.RoleAssistant, Content: "three"},
335 }
336
337 latest := historyPageFromProviderMessages(
338 msgs,
339 func(content string) string { return content },
340 nil,
341 map[int]int{1: 0, 5: 2, 8: 3},
342 0,
343 2,
344 )
345 if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder {
346 t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest)
347 }
348 if len(latest.Messages) != 5 {
349 t.Fatalf("latest page length = %d, want 5: %+v", len(latest.Messages), latest.Messages)
350 }
351 if latest.Messages[0].Role != "user" || latest.Messages[0].Content != "second" {
352 t.Fatalf("first latest message = %+v, want second user", latest.Messages[0])
353 }
354 if latest.Messages[0].CheckpointTurn == nil || *latest.Messages[0].CheckpointTurn != 2 {
355 t.Fatalf("second user checkpoint = %v, want 2", latest.Messages[0].CheckpointTurn)
356 }
357 if latest.Messages[2].Role != "notice" ||
358 latest.Messages[2].Code != event.NoticeCodeUnappliedSteer ||
359 latest.Messages[2].Level != "warn" ||
360 !strings.Contains(latest.Messages[2].Content, "not applied") ||
361 !strings.Contains(latest.Messages[2].Content, "update the plan") {
362 t.Fatalf("steer message = %+v, want explicit unapplied notice in second turn window", latest.Messages[2])
363 }
364 if latest.Messages[3].Role != "user" || latest.Messages[3].Content != "third" {
365 t.Fatalf("third latest message = %+v, want third user", latest.Messages[3])
366 }
367 if latest.Messages[3].CheckpointTurn == nil || *latest.Messages[3].CheckpointTurn != 3 {
368 t.Fatalf("third user checkpoint = %v, want 3", latest.Messages[3].CheckpointTurn)
369 }
370
371 older := historyPageFromProviderMessages(
372 msgs,
373 func(content string) string { return content },
374 nil,
375 map[int]int{1: 0, 5: 2, 8: 3},
376 latest.StartTurn,
377 2,
378 )
379 if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder {
380 t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older)
381 }
382 if len(older.Messages) != 4 || older.Messages[0].Role != "system" || older.Messages[1].Content != "first" || older.Messages[3].Content != "hidden continuation" {
383 t.Fatalf("older page messages = %+v, want prelude and first visible turn", older.Messages)
384 }
385 }
386
387 func TestHistoryCheckpointTurnsSkipsHiddenUsers(t *testing.T) {
388 msgs := []provider.Message{
389 {Role: provider.RoleUser, Content: "first visible"},
390 {Role: provider.RoleAssistant, Content: "ok"},
391 {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."},
392 {Role: provider.RoleUser, Content: "second visible"},
393 }
394 got := historyCheckpointTurns(
395 msgs,
396 func(content string) string { return content },
397 map[int]int{0: 0, 2: 1, 3: 2},
398 )
399 if len(got) != 2 || got[0] != 0 || got[1] != 2 {
400 t.Fatalf("checkpoint turns = %v, want [0 2]", got)
401 }
402 }
403
404 func TestHistoryForTabRestoresPlannerDisplayAfterReload(t *testing.T) {
405 dir := t.TempDir()
406 path := filepath.Join(dir, "session.jsonl")
407 handoff := strings.Join([]string{
408 "# Reasonix executor handoff",
409 "",
410 "You are the executor now.",
411 "",
412 "Original task:",
413 "fix the sandbox reload bug",
414 "",
415 "Planner output:",
416 "inspect settings rebuild and preserve planner display",
417 "",
418 "Executor instructions:",
419 "- apply the fix",
420 }, "\n")
421
422 sess := agent.NewSession("system")
423 sess.Add(provider.Message{Role: provider.RoleUser, Content: handoff})
424 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "executor kept working"})
425 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
426 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: path, Sink: event.Discard})
427 t.Cleanup(ctrl.Close)
428 if err := recordSessionDisplay(dir, path, handoff, "fix the sandbox reload bug"); err != nil {
429 t.Fatalf("recordSessionDisplay: %v", err)
430 }
431
432 app := &App{
433 tabs: map[string]*WorkspaceTab{},
434 activeTabID: "planner_tab",
435 }
436 tab := &WorkspaceTab{ID: "planner_tab", Scope: "global", Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
437 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
438 app.tabs[tab.ID] = tab
439
440 tab.sink.Emit(event.Event{Kind: event.TurnStarted})
441 tab.sink.Emit(event.Event{Kind: event.Phase, Text: "deepseek-v4-pro · planning", Source: event.UsageSourcePlanner})
442 tab.sink.Emit(event.Event{Kind: event.Reasoning, Text: "planner thinking\n", Source: event.UsageSourcePlanner})
443 tab.sink.Emit(event.Event{Kind: event.Text, Text: "planner visible plan", Source: event.UsageSourcePlanner})
444 tab.sink.Emit(event.Event{Kind: event.Message, Text: "planner visible plan", Reasoning: "planner thinking\n", Source: event.UsageSourcePlanner})
445 tab.sink.Emit(event.Event{Kind: event.TurnStarted})
446 tab.sink.Emit(event.Event{Kind: event.Text, Text: "executor kept working", Source: event.UsageSourceExecutor})
447 tab.sink.Emit(event.Event{Kind: event.Message, Text: "executor kept working", Source: event.UsageSourceExecutor})
448 tab.sink.Emit(event.Event{Kind: event.TurnDone})
449 waitForAutosaveIdle(t, tab)
450
451 got := app.HistoryForTab(tab.ID)
452 if len(got) != 5 {
453 t.Fatalf("history length = %d, want user + planner phase + planner answer + executor answer (plus system skipped later by UI): %+v", len(got), got)
454 }
455 if got[1].Content != "fix the sandbox reload bug" {
456 t.Fatalf("user display content = %q, want original prompt", got[1].Content)
457 }
458 if got[2].Role != "phase" || !strings.Contains(got[2].Content, "planning") {
459 t.Fatalf("planner phase missing after reload: %+v", got)
460 }
461 if got[3].Role != "assistant" || got[3].Content != "planner visible plan" || got[3].Reasoning != "planner thinking\n" {
462 t.Fatalf("planner assistant display missing after reload: %+v", got[3])
463 }
464 if got[4].Role != "assistant" || got[4].Content != "executor kept working" {
465 t.Fatalf("executor answer missing after reload: %+v", got[4])
466 }
467 }
468
469 type cancelledDisplayRunner struct {
470 session *agent.Session
471 sink event.Sink
472 started chan struct{}
473 }
474
475 type blockingPlannerProvider struct {
476 started chan struct{}
477 }
478
479 func (p *blockingPlannerProvider) Name() string { return "blocking-planner" }
480
481 func (p *blockingPlannerProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
482 close(p.started)
483 <-ctx.Done()
484 return nil, ctx.Err()
485 }
486
487 func (r *cancelledDisplayRunner) Run(ctx context.Context, input string) error {
488 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
489 r.session.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "checking settings\n", ToolCalls: []provider.ToolCall{{
490 ID: "call_1", Name: "read_file", Arguments: `{"path":"settings.json"}`,
491 }}})
492 r.session.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "partial settings"})
493 r.sink.Emit(event.Event{Kind: event.Reasoning, Text: "checking settings\n", Source: event.UsageSourceExecutor})
494 r.sink.Emit(event.Event{Kind: event.ToolDispatch, Source: event.UsageSourceExecutor, Tool: event.Tool{
495 ID: "call_1", Name: "read_file", Args: `{"path":"settings.json"}`, ReadOnly: true,
496 }})
497 r.sink.Emit(event.Event{Kind: event.ToolResult, Source: event.UsageSourceExecutor, Tool: event.Tool{
498 ID: "call_1", Name: "read_file", Output: "partial settings", Err: "cancelled",
499 }})
500 close(r.started)
501 <-ctx.Done()
502 return ctx.Err()
503 }
504
505 func TestHistoryForTabRestoresCancelledExecutorDisplayAfterReload(t *testing.T) {
506 dir := t.TempDir()
507 path := agent.NewSessionPath(dir, "test-model")
508 sess := agent.NewSession("system")
509 app := &App{tabs: map[string]*WorkspaceTab{}, activeTabID: "cancelled_tab"}
510 tab := &WorkspaceTab{ID: "cancelled_tab", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
511 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
512 runner := &cancelledDisplayRunner{session: sess, sink: tab.sink, started: make(chan struct{})}
513 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
514 ctrl := control.New(control.Options{Runner: runner, Executor: ag, SessionDir: dir, SessionPath: path, Sink: tab.sink})
515 t.Cleanup(ctrl.Close)
516 tab.Ctrl = ctrl
517 app.tabs[tab.ID] = tab
518
519 ctrl.Send("continue setup")
520 select {
521 case <-runner.started:
522 case <-time.After(5 * time.Second):
523 t.Fatal("cancelled turn did not start")
524 }
525 ctrl.Cancel()
526 waitNotRunning(t, ctrl)
527 waitForAutosaveIdle(t, tab)
528
529 if got := ctrl.History(); len(got) != 5 || !got[4].LocalOnly {
530 t.Fatalf("stored transcript should retain user + completed pair + local recovery: %+v", got)
531 }
532 got := app.HistoryForTab(tab.ID)
533 if len(got) != 5 {
534 t.Fatalf("history length = %d, want system + user + assistant + tool + notice: %+v", len(got), got)
535 }
536 if got[1].Role != "user" || got[1].Content != "continue setup" {
537 t.Fatalf("cancelled turn user missing after reload: %+v", got[1])
538 }
539 if got[2].Role != "assistant" || got[2].Reasoning != "checking settings\n" || len(got[2].ToolCalls) != 1 || got[2].ToolCalls[0].Name != "read_file" {
540 t.Fatalf("cancelled assistant display missing after reload: %+v", got[2])
541 }
542 if got[3].Role != "tool" || got[3].ToolName != "read_file" || got[3].Content != "partial settings" || got[3].ToolResultError != "partial settings" {
543 t.Fatalf("cancelled tool display missing after reload: %+v", got[3])
544 }
545 if got[4].Role != "notice" || got[4].Code != event.NoticeCodeCancelledTurn {
546 t.Fatalf("cancelled turn context notice missing after reload: %+v", got[4])
547 }
548 }
549
550 func TestHistoryForTabRestoresPlannerDisplayWhenCancelledBeforeExecutorStarts(t *testing.T) {
551 dir := t.TempDir()
552 path := agent.NewSessionPath(dir, "test-model")
553 app := &App{tabs: map[string]*WorkspaceTab{}, activeTabID: "planner_cancelled_tab"}
554 tab := &WorkspaceTab{ID: "planner_cancelled_tab", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
555 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
556 executorSession := agent.NewSession("system")
557 executor := agent.New(stubProvider{}, tool.NewRegistry(), executorSession, agent.Options{}, tab.sink)
558 planner := &blockingPlannerProvider{started: make(chan struct{})}
559 runner := agent.NewCoordinator(planner, agent.NewSession("planner system"), nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, executor, 0, tab.sink, nil)
560 ctrl := control.New(control.Options{Runner: runner, Executor: executor, SessionDir: dir, SessionPath: path, Sink: tab.sink})
561 defer ctrl.Close()
562 ctrl.SetPlanMode(true)
563 tab.Ctrl = ctrl
564 app.tabs[tab.ID] = tab
565
566 ctrl.Send("new question")
567 select {
568 case <-planner.started:
569 case <-time.After(5 * time.Second):
570 t.Fatal("planner did not start")
571 }
572 ctrl.Cancel()
573 waitNotRunning(t, ctrl)
574 waitForAutosaveIdle(t, tab)
575
576 canonical := ctrl.History()
577 if len(canonical) != 3 || canonical[1].Role != provider.RoleUser || canonical[1].Content != "new question" || !canonical[2].LocalOnly {
578 t.Fatalf("canonical history = %+v, want user plus provider-excluded recovery marker", canonical)
579 }
580 snapshot, snapshotErr := ctrl.TranscriptSnapshot(transcript.PageRequest{})
581 if snapshotErr != nil {
582 t.Fatal(snapshotErr)
583 }
584 users := 0
585 for _, record := range snapshot.Records {
586 if record.Message.Role == "user" {
587 users++
588 if record.Message.MessageID != canonical[1].ID {
589 t.Fatalf("display user ID %q differs from recovered canonical ID %q", record.Message.MessageID, canonical[1].ID)
590 }
591 }
592 }
593 if users != 1 {
594 t.Fatalf("snapshot users = %d, want one identified user", users)
595 }
596 visible := app.HistoryForTab(tab.ID)
597 if len(visible) != 4 {
598 t.Fatalf("visible history length = %d, want system + user + planner phase + notice: %+v", len(visible), visible)
599 }
600 if visible[1].Role != "user" || visible[1].Content != "new question" {
601 t.Fatalf("cancelled planner user missing after reload: %+v", visible[1])
602 }
603 if visible[2].Role != "phase" || !strings.Contains(visible[2].Content, "planning") {
604 t.Fatalf("cancelled planner display missing after reload: %+v", visible[2])
605 }
606 if visible[3].Role != "notice" || visible[3].Code != event.NoticeCodeCancelledTurn {
607 t.Fatalf("cancelled planner context notice missing after reload: %+v", visible[3])
608 }
609 }
610
611 func TestCancelledExecutorDisplayFollowsDetachedAndReattachedRuntime(t *testing.T) {
612 dir := t.TempDir()
613 path := agent.NewSessionPath(dir, "test-model")
614 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}, activeTabID: "source_tab"}
615 source := &WorkspaceTab{ID: "source_tab", Scope: "global", Ready: true, SessionPath: path, disabledMCP: map[string]ServerView{}}
616 source.sink = &tabEventSink{tabID: source.ID, app: app}
617 sess := agent.NewSession("system")
618 runner := &cancelledDisplayRunner{session: sess, sink: source.sink, started: make(chan struct{})}
619 executor := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
620 ctrl := control.New(control.Options{Runner: runner, Executor: executor, SessionDir: dir, SessionPath: path, Sink: source.sink})
621 defer ctrl.Close()
622 source.Ctrl = ctrl
623 app.tabs[source.ID] = source
624
625 ctrl.Send("continue setup")
626 select {
627 case <-runner.started:
628 case <-time.After(5 * time.Second):
629 t.Fatal("cancelled turn did not start")
630 }
631 if !app.detachRuntimeForReplacement(source) {
632 t.Fatal("running session could not be detached")
633 }
634 key := sessionRuntimeKey(path)
635 detached := app.detachedSessions[key]
636 if detached == nil {
637 t.Fatal("detached runtime missing")
638 }
639 target := &WorkspaceTab{ID: "reattached_tab", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
640 app.mu.Lock()
641 delete(app.tabs, source.ID)
642 app.tabs[target.ID] = target
643 delete(app.detachedSessions, key)
644 applyRuntimeTab(target, detached, path, app.ctx, app)
645 app.activeTabID = target.ID
646 app.mu.Unlock()
647 target.sink.Emit(event.Event{Kind: event.Text, Text: "after reattach", Source: event.UsageSourceExecutor})
648
649 ctrl.Cancel()
650 waitNotRunning(t, ctrl)
651 waitForAutosaveIdle(t, target)
652 visible := app.HistoryForTab(target.ID)
653 var sawBefore, sawAfter, sawNotice bool
654 for _, message := range visible {
655 if message.Role == "assistant" && message.Reasoning == "checking settings\n" {
656 sawBefore = true
657 }
658 if message.Role == "assistant" && message.Content == "after reattach" {
659 sawAfter = true
660 }
661 if message.Role == "notice" && message.Code == event.NoticeCodeCancelledTurn {
662 sawNotice = true
663 }
664 }
665 if !sawBefore || !sawAfter || !sawNotice {
666 t.Fatalf("reattached cancelled history lost display state: before=%v after=%v notice=%v history=%+v", sawBefore, sawAfter, sawNotice, visible)
667 }
668 }
669
670 func TestHistoryForTabUsesPinnedSessionBeforeControllerReady(t *testing.T) {
671 isolateDesktopUserDirs(t)
672 root := globalTabWorkspaceRoot()
673 dir := desktopSessionDir(root)
674 if err := os.MkdirAll(dir, 0o755); err != nil {
675 t.Fatalf("mkdir session dir: %v", err)
676 }
677 path := filepath.Join(dir, "pending-controller.jsonl")
678 writeHistoryTestSession(t, path, "warm prompt")
679
680 app := NewApp()
681 tab := &WorkspaceTab{
682 ID: "pending",
683 Scope: "global",
684 WorkspaceRoot: root,
685 SessionPath: path,
686 Ready: false,
687 disabledMCP: map[string]ServerView{},
688 }
689 app.tabs[tab.ID] = tab
690 app.tabOrder = []string{tab.ID}
691 app.activeTabID = tab.ID
692
693 got := app.HistoryForTab(tab.ID)
694 if len(got) != 1 || got[0].Role != "user" || got[0].Content != "warm prompt" {
695 t.Fatalf("pending controller history = %+v, want warm prompt", got)
696 }
697 }
698
699 func historyMemoryCompilerContract(t *testing.T, sourceEvent string) string {
700 t.Helper()
701 body, err := json.Marshal(map[string]any{
702 "type": "memory_v5_execution_contract",
703 "planner_ir": map[string]any{
704 "source_event": sourceEvent,
705 },
706 })
707 if err != nil {
708 t.Fatal(err)
709 }
710 return "<memory-compiler-execution>\n" + string(body) + "\n</memory-compiler-execution>"
711 }
712
713 func assertNoHistoryMemoryContract(t *testing.T, text string) {
714 t.Helper()
715 if strings.Contains(text, "<memory-compiler-execution>") ||
716 strings.Contains(text, "</memory-compiler-execution>") ||
717 strings.Contains(text, "memory_v5_execution_contract") ||
718 strings.Contains(text, "planner_ir") {
719 t.Fatalf("history leaked Memory v5 contract content: %q", text)
720 }
721 }
722
723 func TestHistoryMessagesArchiveCompletedToolPayloads(t *testing.T) {
724 largeArgs := `{"command":"` + strings.Repeat("printf x;", 300) + `"}`
725 largeOutput := strings.Repeat("line of output\n", 600)
726 msgs := []provider.Message{
727 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
728 ID: "call_large", Name: "bash", Arguments: largeArgs,
729 }}},
730 {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_large", Content: largeOutput},
731 }
732
733 got := historyMessages(msgs, func(content string) string { return content })
734 if len(got) != 2 {
735 t.Fatalf("history length = %d, want 2", len(got))
736 }
737 call := got[0].ToolCalls[0]
738 if !call.ArgumentsArchived {
739 t.Fatalf("tool arguments were not marked archived: %+v", call)
740 }
741 if call.Arguments != "" {
742 t.Fatalf("archived tool arguments should be omitted from initial history, got %d bytes", len(call.Arguments))
743 }
744 if call.Subject == "" {
745 t.Fatalf("archived tool call should keep a collapsed subject: %+v", call)
746 }
747 if call.Summary == "" {
748 t.Fatalf("archived tool call should keep a collapsed summary: %+v", call)
749 }
750 result := got[1]
751 if !result.ToolResultArchived {
752 t.Fatalf("tool result was not marked archived: %+v", result)
753 }
754 if result.Content != "" {
755 t.Fatalf("archived successful tool output should be omitted from initial history, got %d bytes", len(result.Content))
756 }
757 encoded, err := json.Marshal(got)
758 if err != nil {
759 t.Fatal(err)
760 }
761 if strings.Contains(string(encoded), largeArgs) || strings.Contains(string(encoded), largeOutput) {
762 t.Fatalf("initial history JSON still contains large args/output: %d bytes", len(encoded))
763 }
764 }
765
766 func TestHistoryMessagesPreserveResolvedCapabilityMetadata(t *testing.T) {
767 resolvedReadOnly := false
768 msgs := []provider.Message{
769 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
770 ID: "call_capability", Name: "use_capability",
771 Arguments: `{"action":"call","capability_id":"mcp-tool:db/write"}`,
772 ResolvedName: "mcp__db__write",
773 CapabilityID: "mcp-tool:db/write",
774 ResolvedReadOnly: &resolvedReadOnly,
775 }}},
776 {Role: provider.RoleTool, Name: "use_capability", ToolCallID: "call_capability", Content: "done"},
777 }
778
779 got := historyMessages(msgs, func(content string) string { return content })
780 if len(got) != 2 || len(got[0].ToolCalls) != 1 {
781 t.Fatalf("history = %+v", got)
782 }
783 call := got[0].ToolCalls[0]
784 if call.ResolvedName != "mcp__db__write" || call.CapabilityID != "mcp-tool:db/write" ||
785 call.ResolvedReadOnly == nil || *call.ResolvedReadOnly {
786 t.Fatalf("resolved capability metadata = %+v", call)
787 }
788 }
789
790 func TestHistoryMessagesKeepRunSkillSubjectWhenArchived(t *testing.T) {
791 args := `{"name":"code-reviewer","arguments":"review this branch"}`
792 msgs := []provider.Message{
793 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
794 ID: "call_skill", Name: "run_skill", Arguments: args,
795 }}},
796 {Role: provider.RoleTool, Name: "run_skill", ToolCallID: "call_skill", Content: "Skill completed"},
797 }
798
799 got := historyMessages(msgs, func(content string) string { return content })
800 if len(got) != 2 {
801 t.Fatalf("history length = %d, want 2", len(got))
802 }
803 call := got[0].ToolCalls[0]
804 if !call.ArgumentsArchived || call.Arguments != "" {
805 t.Fatalf("run_skill arguments should be archived after completion: %+v", call)
806 }
807 if call.Subject != "code-reviewer" {
808 t.Fatalf("run_skill subject = %q, want code-reviewer", call.Subject)
809 }
810 }
811
812 func TestHistoryMessagesKeepToolFileDiffMetadata(t *testing.T) {
813 diff := "@@ -27 +27 @@\n-func save():\n+func save_file():\n"
814 msgs := []provider.Message{
815 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
816 ID: "edit",
817 Name: "edit_file",
818 Arguments: `{"path":"settings/settings_IO.gd","old_string":"func save():","new_string":"func save_file():"}`,
819 Diff: diff,
820 Added: 1,
821 Removed: 1,
822 }}},
823 {Role: provider.RoleTool, Name: "edit_file", ToolCallID: "edit", Content: "edited settings/settings_IO.gd"},
824 }
825
826 got := historyMessages(msgs, func(content string) string { return content })
827 call := got[0].ToolCalls[0]
828 if call.Diff != diff || call.Added != 1 || call.Removed != 1 {
829 t.Fatalf("history tool diff metadata = diff:%q +%d -%d", call.Diff, call.Added, call.Removed)
830 }
831 if !call.ArgumentsArchived || call.Arguments != "" {
832 t.Fatalf("tool arguments should still be archived: %+v", call)
833 }
834 }
835
836 func TestHistoryMessagesKeepBoundedToolErrors(t *testing.T) {
837 largeError := "error: " + strings.Repeat("permission denied ", 400)
838 msgs := []provider.Message{
839 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
840 ID: "call_error", Name: "bash", Arguments: `{"command":"rm protected"}`,
841 }}},
842 {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_error", Content: largeError},
843 }
844
845 got := historyMessages(msgs, func(content string) string { return content })
846 result := got[1]
847 if result.ToolResultError == "" {
848 t.Fatalf("failed tool result should keep an error preview: %+v", result)
849 }
850 if result.Content != result.ToolResultError {
851 t.Fatalf("tool result content and error preview diverged: content=%q error=%q", result.Content, result.ToolResultError)
852 }
853 if len(result.Content) >= len(largeError) {
854 t.Fatalf("failed tool result preview was not bounded: got %d want < %d", len(result.Content), len(largeError))
855 }
856 if !strings.HasPrefix(result.Content, "error: permission denied") {
857 t.Fatalf("failed tool result preview lost useful prefix: %q", result.Content[:min(len(result.Content), 80)])
858 }
859 if !result.ToolResultArchived {
860 t.Fatalf("bounded failed tool result should still be marked archived for on-demand full data: %+v", result)
861 }
862 }
863
864 func TestHistoryMessagesClipToolErrorsAtUTF8Boundary(t *testing.T) {
865 largeError := "error: " + strings.Repeat("权限不足", 1000)
866 msgs := []provider.Message{
867 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
868 ID: "call_unicode_error", Name: "bash", Arguments: `{"command":"rm 受保护文件"}`,
869 }}},
870 {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_unicode_error", Content: largeError},
871 }
872
873 got := historyMessages(msgs, func(content string) string { return content })
874 result := got[1]
875 if result.ToolResultError == "" {
876 t.Fatalf("failed tool result should keep an error preview: %+v", result)
877 }
878 if !utf8.ValidString(result.ToolResultError) {
879 t.Fatalf("failed tool result preview is not valid UTF-8: %q", result.ToolResultError)
880 }
881 if len(result.ToolResultError) >= len(largeError) {
882 t.Fatalf("failed tool result preview was not bounded: got %d want < %d", len(result.ToolResultError), len(largeError))
883 }
884 }
885
886 func TestHistoryMessagesKeepTodoWriteArguments(t *testing.T) {
887 args := `{"todos":[{"content":"A","status":"in_progress"}]}`
888 msgs := []provider.Message{
889 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
890 ID: "todo_1", Name: "todo_write", Arguments: args,
891 }}},
892 {Role: provider.RoleTool, Name: "todo_write", ToolCallID: "todo_1", Content: "Todos updated"},
893 }
894
895 got := historyMessages(msgs, func(content string) string { return content })
896 call := got[0].ToolCalls[0]
897 if call.ArgumentsArchived {
898 t.Fatalf("todo_write arguments must remain available for restored todo panel: %+v", call)
899 }
900 if call.Arguments != args {
901 t.Fatalf("todo_write arguments = %q, want %q", call.Arguments, args)
902 }
903 }
904
905 func TestHistoryMessagesPreserveUnaddressableToolPayloads(t *testing.T) {
906 args := `{"command":"legacy"}`
907 output := "legacy output\n" + strings.Repeat("detail\n", 8)
908 msgs := []provider.Message{
909 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
910 Name: "bash", Arguments: args,
911 }}},
912 {Role: provider.RoleTool, Name: "bash", Content: output},
913 }
914
915 got := historyMessages(msgs, func(content string) string { return content })
916 call := got[0].ToolCalls[0]
917 if call.ArgumentsArchived {
918 t.Fatalf("tool call without an id cannot be archived for later lookup: %+v", call)
919 }
920 if call.Arguments != args {
921 t.Fatalf("tool call without an id should keep args, got %q", call.Arguments)
922 }
923 result := got[1]
924 if result.ToolResultArchived {
925 t.Fatalf("tool result without an id cannot be archived for later lookup: %+v", result)
926 }
927 if result.Content != output {
928 t.Fatalf("tool result without an id should keep output, got %q", result.Content)
929 }
930 }
931
932 func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T) {
933 isolateDesktopUserDirs(t)
934 root := globalTabWorkspaceRoot()
935 dir := desktopSessionDir(root)
936 if err := os.MkdirAll(dir, 0o755); err != nil {
937 t.Fatalf("mkdir session dir: %v", err)
938 }
939
940 currentPath := filepath.Join(dir, "current.jsonl")
941 targetPath := filepath.Join(dir, "target.jsonl")
942 writeHistoryTestSession(t, currentPath, "current prompt")
943 writeHistoryTestSession(t, targetPath, "target prompt")
944 if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{
945 TokenMode: boot.TokenModeFull,
946 Mode: "yolo",
947 ToolApprovalMode: control.ToolApprovalYolo,
948 }); err != nil {
949 t.Fatalf("SaveBranchMetaPreserveUpdated target: %v", err)
950 }
951
952 loaded, err := agent.LoadSession(targetPath)
953 if err != nil {
954 t.Fatalf("LoadSession: %v", err)
955 }
956 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard})
957 ctrl.SetMode(true, false)
958 ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
959 defer ctrl.Close()
960
961 app := NewApp()
962 tab := &WorkspaceTab{
963 ID: "tab",
964 Scope: "global",
965 WorkspaceRoot: root,
966 SessionPath: currentPath,
967 Ctrl: ctrl,
968 Ready: true,
969 mode: "plan",
970 toolApprovalMode: control.ToolApprovalAuto,
971 sink: &tabEventSink{tabID: "tab", app: app},
972 disabledMCP: map[string]ServerView{},
973 }
974 app.tabs[tab.ID] = tab
975 app.tabOrder = []string{tab.ID}
976 app.activeTabID = tab.ID
977
978 if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
979 t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
980 }
981
982 currentMeta, ok, err := agent.LoadBranchMeta(currentPath)
983 if err != nil || !ok {
984 t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
985 }
986 if currentMeta.TokenMode != boot.TokenModeFull || currentMeta.AgentPreset != boot.AgentPresetStandard ||
987 currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
988 t.Fatalf("current session profile = token:%q preset:%q mode:%q approval:%q, want full/standard/plan/auto",
989 currentMeta.TokenMode, currentMeta.AgentPreset, currentMeta.Mode, currentMeta.ToolApprovalMode)
990 }
991 if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
992 t.Fatalf("rebound token mode = %q, want full", got)
993 }
994 if got := currentTabMode(tab); got != "yolo" {
995 t.Fatalf("rebound mode = %q, want yolo", got)
996 }
997 if got := currentTabToolApprovalMode(tab); got != control.ToolApprovalYolo {
998 t.Fatalf("rebound tool approval = %q, want yolo", got)
999 }
1000 }
1001
1002 func TestRebindTabToDetachedSessionPreservesRunningSourceRuntime(t *testing.T) {
1003 isolateDesktopUserDirs(t)
1004 root := globalTabWorkspaceRoot()
1005 dir := desktopSessionDir(root)
1006 if err := os.MkdirAll(dir, 0o755); err != nil {
1007 t.Fatalf("mkdir session dir: %v", err)
1008 }
1009
1010 sourcePath := filepath.Join(dir, "running-source.jsonl")
1011 targetPath := filepath.Join(dir, "detached-target.jsonl")
1012 writeHistoryTestSession(t, sourcePath, "source prompt")
1013 writeHistoryTestSession(t, targetPath, "target prompt")
1014 loaded, err := agent.LoadSession(targetPath)
1015 if err != nil {
1016 t.Fatalf("load target: %v", err)
1017 }
1018
1019 app := NewApp()
1020 app.ctx = context.Background()
1021 app.readyHook = func() {}
1022
1023 sourceRunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
1024 sourceSink := &tabEventSink{tabID: "visible", app: app, ctx: app.ctx}
1025 targetSink := &tabEventSink{tabID: "detached", app: app}
1026 installNoopRuntimeEvents(app, sourceSink, targetSink)
1027 sourceCtrl := control.New(control.Options{
1028 Runner: sourceRunner, SessionDir: dir, SessionPath: sourcePath,
1029 Label: "source", Sink: sourceSink,
1030 })
1031 targetCtrl := control.New(control.Options{
1032 SessionDir: dir, SessionPath: targetPath, Label: "target", Sink: targetSink,
1033 })
1034 tab := &WorkspaceTab{
1035 ID: "visible", Scope: "global", WorkspaceRoot: root,
1036 SessionPath: sourcePath, Ctrl: sourceCtrl, Ready: true, sink: sourceSink,
1037 disabledMCP: map[string]ServerView{},
1038 }
1039 app.tabs[tab.ID] = tab
1040 app.tabOrder = []string{tab.ID}
1041 app.activeTabID = tab.ID
1042 if err := tab.ensureSessionLease(sourcePath); err != nil {
1043 t.Fatalf("lease source: %v", err)
1044 }
1045 app.mu.Lock()
1046 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(sourcePath))
1047 app.advanceSessionRuntimeEpochLocked(tab)
1048 app.mu.Unlock()
1049
1050 targetLease, err := agent.TryAcquireSessionLease(targetPath)
1051 if err != nil {
1052 t.Fatalf("lease target: %v", err)
1053 }
1054 detachedTarget := &WorkspaceTab{
1055 ID: detachedRuntimeTabID(sessionRuntimeKey(targetPath)), Scope: "global",
1056 WorkspaceRoot: root, SessionPath: targetPath, Ctrl: targetCtrl,
1057 Ready: true, sink: targetSink, disabledMCP: map[string]ServerView{},
1058 }
1059 detachedTarget.adoptSessionLease(targetLease)
1060 app.mu.Lock()
1061 app.detachedSessions[sessionRuntimeKey(targetPath)] = detachedTarget
1062 app.newSessionRuntimeLocked(detachedTarget, sessionRuntimeKey(targetPath))
1063 app.advanceSessionRuntimeEpochLocked(detachedTarget)
1064 app.mu.Unlock()
1065 sourceReleased := false
1066 t.Cleanup(func() {
1067 if !sourceReleased {
1068 close(sourceRunner.release)
1069 }
1070 sourceCtrl.Close()
1071 targetCtrl.Close()
1072 tab.releaseSessionLease()
1073 app.mu.RLock()
1074 detachedSource := app.detachedSessions[sessionRuntimeKey(sourcePath)]
1075 app.mu.RUnlock()
1076 if detachedSource != nil {
1077 detachedSource.releaseSessionLease()
1078 }
1079 })
1080
1081 sourceCtrl.Submit("keep source running")
1082 select {
1083 case <-sourceRunner.started:
1084 case <-time.After(5 * time.Second):
1085 t.Fatal("source turn did not start")
1086 }
1087
1088 if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
1089 t.Fatalf("reattach target: %v", err)
1090 }
1091 if tab.Ctrl != targetCtrl || tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(targetPath) {
1092 t.Fatalf("visible target runtime = ctrl %p lease %q, want %p/%q",
1093 tab.Ctrl, tab.sessionLeaseRuntimeKey(), targetCtrl, sessionRuntimeKey(targetPath))
1094 }
1095 if !sourceCtrl.Running() {
1096 t.Fatal("reattaching the target cancelled the running source controller")
1097 }
1098 app.mu.RLock()
1099 detachedSource := app.detachedSessions[sessionRuntimeKey(sourcePath)]
1100 targetStillDetached := app.detachedSessions[sessionRuntimeKey(targetPath)]
1101 app.mu.RUnlock()
1102 if detachedSource == nil || detachedSource.Ctrl != sourceCtrl ||
1103 detachedSource.sessionLeaseRuntimeKey() != sessionRuntimeKey(sourcePath) {
1104 t.Fatalf("running source was not preserved as detached runtime: %#v", detachedSource)
1105 }
1106 if targetStillDetached != nil {
1107 t.Fatalf("target remained detached after reattach: %#v", targetStillDetached)
1108 }
1109
1110 close(sourceRunner.release)
1111 sourceReleased = true
1112 waitNotRunning(t, sourceCtrl)
1113 }
1114
1115 func TestRebindTabToDetachedSessionReleasesIdleSourceSharedHost(t *testing.T) {
1116 isolateDesktopUserDirs(t)
1117 root := globalTabWorkspaceRoot()
1118 dir := desktopSessionDir(root)
1119 if err := os.MkdirAll(dir, 0o755); err != nil {
1120 t.Fatalf("mkdir session dir: %v", err)
1121 }
1122
1123 sourcePath := filepath.Join(dir, "idle-source.jsonl")
1124 targetPath := filepath.Join(dir, "detached-target.jsonl")
1125 writeHistoryTestSession(t, sourcePath, "source prompt")
1126 writeHistoryTestSession(t, targetPath, "target prompt")
1127 loaded, err := agent.LoadSession(targetPath)
1128 if err != nil {
1129 t.Fatalf("load target: %v", err)
1130 }
1131
1132 app := NewApp()
1133 app.ctx = context.Background()
1134 app.readyHook = func() {}
1135 hostKey := root
1136 sharedHost := app.acquireSharedHost(hostKey)
1137 if got := app.acquireSharedHost(hostKey); got != sharedHost {
1138 t.Fatal("source and detached target did not share one plugin host")
1139 }
1140
1141 sourceSink := &tabEventSink{tabID: "visible", app: app, ctx: app.ctx}
1142 targetSink := &tabEventSink{tabID: "detached", app: app}
1143 installNoopRuntimeEvents(app, sourceSink, targetSink)
1144 sourceCtrl := control.New(control.Options{
1145 SessionDir: dir, SessionPath: sourcePath, Label: "source",
1146 Sink: sourceSink, Host: sharedHost,
1147 })
1148 targetCtrl := control.New(control.Options{
1149 SessionDir: dir, SessionPath: targetPath, Label: "target",
1150 Sink: targetSink, Host: sharedHost,
1151 })
1152 tab := &WorkspaceTab{
1153 ID: "visible", Scope: "global", WorkspaceRoot: root,
1154 SessionPath: sourcePath, Ctrl: sourceCtrl, Ready: true, sink: sourceSink,
1155 SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
1156 }
1157 app.tabs[tab.ID] = tab
1158 app.tabOrder = []string{tab.ID}
1159 app.activeTabID = tab.ID
1160 if err := tab.ensureSessionLease(sourcePath); err != nil {
1161 t.Fatalf("lease source: %v", err)
1162 }
1163 app.mu.Lock()
1164 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(sourcePath))
1165 app.advanceSessionRuntimeEpochLocked(tab)
1166 app.mu.Unlock()
1167
1168 targetLease, err := agent.TryAcquireSessionLease(targetPath)
1169 if err != nil {
1170 t.Fatalf("lease target: %v", err)
1171 }
1172 detachedTarget := &WorkspaceTab{
1173 ID: detachedRuntimeTabID(sessionRuntimeKey(targetPath)), Scope: "global",
1174 WorkspaceRoot: root, SessionPath: targetPath, Ctrl: targetCtrl,
1175 Ready: true, sink: targetSink, SharedHostKey: hostKey,
1176 disabledMCP: map[string]ServerView{},
1177 }
1178 detachedTarget.adoptSessionLease(targetLease)
1179 app.mu.Lock()
1180 app.detachedSessions[sessionRuntimeKey(targetPath)] = detachedTarget
1181 app.newSessionRuntimeLocked(detachedTarget, sessionRuntimeKey(targetPath))
1182 app.advanceSessionRuntimeEpochLocked(detachedTarget)
1183 app.mu.Unlock()
1184 t.Cleanup(func() {
1185 sourceCtrl.Close()
1186 targetCtrl.Close()
1187 tab.releaseSessionLease()
1188 detachedTarget.releaseSessionLease()
1189 app.closeAllSharedHosts()
1190 })
1191
1192 if refs, ok := sharedHostRefsForTest(t, app, hostKey); !ok || refs != 2 {
1193 t.Fatalf("shared host refs before reattach = %d, present=%v, want 2/true", refs, ok)
1194 }
1195 if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
1196 t.Fatalf("reattach target: %v", err)
1197 }
1198 if tab.Ctrl != targetCtrl || tab.SharedHostKey != hostKey {
1199 t.Fatalf("visible target runtime = ctrl %p host %q, want %p/%q",
1200 tab.Ctrl, tab.SharedHostKey, targetCtrl, hostKey)
1201 }
1202 if refs, ok := sharedHostRefsForTest(t, app, hostKey); !ok || refs != 1 {
1203 t.Fatalf("shared host refs after reattach = %d, present=%v, want 1/true", refs, ok)
1204 }
1205 }
1206
1207 func newAtomicRebindTestApp(t *testing.T) (*App, *WorkspaceTab, control.SessionAPI, string, string, *agent.Session) {
1208 t.Helper()
1209 isolateDesktopUserDirs(t)
1210 root := globalTabWorkspaceRoot()
1211 dir := desktopSessionDir(root)
1212 if err := os.MkdirAll(dir, 0o755); err != nil {
1213 t.Fatalf("mkdir session dir: %v", err)
1214 }
1215 sourcePath := filepath.Join(dir, "atomic-source.jsonl")
1216 targetPath := filepath.Join(dir, "atomic-target.jsonl")
1217 writeHistoryTestSession(t, sourcePath, "source prompt")
1218 writeHistoryTestSession(t, targetPath, "target prompt")
1219 if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{
1220 TokenMode: boot.TokenModeDelivery,
1221 Mode: "normal",
1222 ToolApprovalMode: control.ToolApprovalAsk,
1223 }); err != nil {
1224 t.Fatalf("save target profile: %v", err)
1225 }
1226 loaded, err := agent.LoadSession(targetPath)
1227 if err != nil {
1228 t.Fatalf("load target: %v", err)
1229 }
1230 sourceSession, err := agent.LoadSession(sourcePath)
1231 if err != nil {
1232 t.Fatalf("load source: %v", err)
1233 }
1234 exec := agent.New(nil, nil, sourceSession, agent.Options{}, event.Discard)
1235 oldCtrl := control.New(control.Options{
1236 Executor: exec, SessionDir: dir, SessionPath: sourcePath, Label: "source", Sink: event.Discard,
1237 })
1238 oldCtrl.Resume(sourceSession, sourcePath)
1239 oldCtrl.SetPlanMode(true)
1240 oldCtrl.SetToolApprovalMode(control.ToolApprovalYolo)
1241
1242 app := NewApp()
1243 app.ctx = context.Background()
1244 app.readyHook = func() {}
1245 tab := &WorkspaceTab{
1246 ID: "atomic-rebind",
1247 Scope: "global",
1248 WorkspaceRoot: root,
1249 SessionPath: sourcePath,
1250 Ctrl: oldCtrl,
1251 Ready: true,
1252 model: "",
1253 mode: "plan-yolo",
1254 toolApprovalMode: control.ToolApprovalYolo,
1255 sink: &tabEventSink{tabID: "atomic-rebind", app: app, ctx: app.ctx},
1256 disabledMCP: map[string]ServerView{},
1257 }
1258 app.tabs[tab.ID] = tab
1259 app.tabOrder = []string{tab.ID}
1260 app.activeTabID = tab.ID
1261 if err := tab.ensureSessionLease(sourcePath); err != nil {
1262 t.Fatalf("lease source: %v", err)
1263 }
1264 app.mu.Lock()
1265 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(sourcePath))
1266 app.advanceSessionRuntimeEpochLocked(tab)
1267 app.mu.Unlock()
1268 t.Cleanup(func() {
1269 if ctrl := app.controllerForTab(tab); ctrl != nil {
1270 ctrl.Close()
1271 }
1272 tab.releaseSessionLease()
1273 })
1274 return app, tab, oldCtrl, sourcePath, targetPath, loaded
1275 }
1276
1277 func assertAtomicRebindFailurePreservedSource(
1278 t *testing.T,
1279 app *App,
1280 tab *WorkspaceTab,
1281 oldCtrl control.SessionAPI,
1282 sourcePath string,
1283 targetPath string,
1284 oldEpoch string,
1285 ) {
1286 t.Helper()
1287 if got := app.controllerForTab(tab); got != oldCtrl {
1288 t.Fatalf("controller after failed rebind = %p, want source %p", got, oldCtrl)
1289 }
1290 if got := tab.currentSessionPath(); sessionRuntimeKey(got) != sessionRuntimeKey(sourcePath) {
1291 t.Fatalf("session path after failed rebind = %q, want %q", got, sourcePath)
1292 }
1293 if got := tab.sessionLeaseRuntimeKey(); got != sessionRuntimeKey(sourcePath) {
1294 t.Fatalf("lease after failed rebind = %q, want source key %q", got, sessionRuntimeKey(sourcePath))
1295 }
1296 if !oldCtrl.PlanMode() || oldCtrl.ToolApprovalMode() != control.ToolApprovalYolo ||
1297 currentTabTokenMode(tab) != boot.TokenModeFull {
1298 t.Fatalf("source profile changed after failed rebind: plan=%v approval=%q token=%q",
1299 oldCtrl.PlanMode(), oldCtrl.ToolApprovalMode(), currentTabTokenMode(tab))
1300 }
1301 app.mu.RLock()
1302 view := app.sessionRuntimeViewLocked(tab)
1303 sourceRuntime := app.runtimeBySessionKey[sessionRuntimeKey(sourcePath)]
1304 targetRuntime := app.runtimeBySessionKey[sessionRuntimeKey(targetPath)]
1305 app.mu.RUnlock()
1306 if view.Phase != sessionRuntimeReady || view.Epoch != oldEpoch {
1307 t.Fatalf("runtime after failed rebind = phase %q epoch %q, want ready/%q", view.Phase, view.Epoch, oldEpoch)
1308 }
1309 if sourceRuntime == nil || sourceRuntime.Owner != tab || targetRuntime != nil {
1310 t.Fatalf("registry after failed rebind source=%#v target=%#v", sourceRuntime, targetRuntime)
1311 }
1312 if meta := app.MetaForTab(tab.ID); !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
1313 t.Fatalf("failed rebind disabled source runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
1314 }
1315 }
1316
1317 func TestRebindTargetLeaseFailureKeepsSourceRuntimeAtomic(t *testing.T) {
1318 app, tab, oldCtrl, sourcePath, targetPath, loaded := newAtomicRebindTestApp(t)
1319 app.mu.RLock()
1320 oldEpoch := app.sessionRuntimeViewLocked(tab).Epoch
1321 app.mu.RUnlock()
1322
1323 holder, err := agent.TryAcquireSessionLease(targetPath)
1324 if err != nil {
1325 t.Fatalf("hold target lease: %v", err)
1326 }
1327 defer holder.Release()
1328
1329 err = app.rebindTabToLoadedSessionPath(tab, targetPath, loaded)
1330 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
1331 t.Fatalf("rebind error = %v, want ErrSessionLeaseHeld", err)
1332 }
1333 assertAtomicRebindFailurePreservedSource(t, app, tab, oldCtrl, sourcePath, targetPath, oldEpoch)
1334 if _, err := agent.TryAcquireSessionLease(sourcePath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
1335 t.Fatalf("source lease became acquirable after failed target claim: %v", err)
1336 }
1337 }
1338
1339 func TestRebindPostLeaseValidationFailureRollsBackCandidate(t *testing.T) {
1340 app, tab, oldCtrl, sourcePath, targetPath, loaded := newAtomicRebindTestApp(t)
1341 app.mu.RLock()
1342 oldEpoch := app.sessionRuntimeViewLocked(tab).Epoch
1343 app.mu.RUnlock()
1344 app.rebindCandidateHook = func(stage string) error {
1345 if stage == "lease_acquired" {
1346 return errors.New("injected post-lease validation failure")
1347 }
1348 return nil
1349 }
1350
1351 err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded)
1352 if err == nil || !strings.Contains(err.Error(), "injected post-lease") {
1353 t.Fatalf("rebind error = %v, want injected validation failure", err)
1354 }
1355 assertAtomicRebindFailurePreservedSource(t, app, tab, oldCtrl, sourcePath, targetPath, oldEpoch)
1356 targetLease, err := agent.TryAcquireSessionLease(targetPath)
1357 if err != nil {
1358 t.Fatalf("candidate target lease leaked after rollback: %v", err)
1359 }
1360 targetLease.Release()
1361 }
1362
1363 func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
1364 isolateDesktopUserDirs(t)
1365 root := globalTabWorkspaceRoot()
1366 dir := desktopSessionDir(root)
1367 if err := os.MkdirAll(dir, 0o755); err != nil {
1368 t.Fatalf("mkdir session dir: %v", err)
1369 }
1370
1371 currentPath := filepath.Join(dir, "profile.jsonl")
1372 otherPath := filepath.Join(dir, "other.jsonl")
1373 writeHistoryTestSession(t, currentPath, "profile prompt")
1374 writeHistoryTestSession(t, otherPath, "other prompt")
1375 if err := agent.SaveBranchMetaPreserveUpdated(currentPath, agent.BranchMeta{}); err != nil {
1376 t.Fatalf("SaveBranchMetaPreserveUpdated current: %v", err)
1377 }
1378
1379 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "profile", Sink: event.Discard})
1380 ctrl.SetMode(true, false)
1381 ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
1382 ctrl.SetGoal("finish the review")
1383
1384 app := NewApp()
1385 tab := &WorkspaceTab{
1386 ID: "profile",
1387 Scope: "global",
1388 WorkspaceRoot: root,
1389 SessionPath: currentPath,
1390 Ctrl: ctrl,
1391 Ready: true,
1392 mode: "plan",
1393 toolApprovalMode: control.ToolApprovalAuto,
1394 sink: &tabEventSink{tabID: "profile", app: app},
1395 disabledMCP: map[string]ServerView{},
1396 }
1397 other := &WorkspaceTab{
1398 ID: "other",
1399 Scope: "global",
1400 WorkspaceRoot: root,
1401 SessionPath: otherPath,
1402 Ready: true,
1403 disabledMCP: map[string]ServerView{},
1404 }
1405 app.tabs[tab.ID] = tab
1406 app.tabs[other.ID] = other
1407 app.tabOrder = []string{tab.ID, other.ID}
1408 app.activeTabID = tab.ID
1409
1410 if err := app.CloseTab(tab.ID); err != nil {
1411 t.Fatalf("CloseTab: %v", err)
1412 }
1413
1414 meta, ok, err := agent.LoadBranchMeta(currentPath)
1415 if err != nil || !ok {
1416 t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
1417 }
1418 if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard ||
1419 meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
1420 t.Fatalf("closed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/standard/plan/auto/goal",
1421 meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
1422 }
1423 }
1424
1425 func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
1426 isolateDesktopUserDirs(t)
1427 root := globalTabWorkspaceRoot()
1428 dir := desktopSessionDir(root)
1429 if err := os.MkdirAll(dir, 0o755); err != nil {
1430 t.Fatalf("mkdir session dir: %v", err)
1431 }
1432
1433 keepPath := filepath.Join(dir, "keep.jsonl")
1434 removedPath := filepath.Join(dir, "removed.jsonl")
1435 writeHistoryTestSession(t, keepPath, "keep prompt")
1436 writeHistoryTestSession(t, removedPath, "removed prompt")
1437 if err := agent.SaveBranchMetaPreserveUpdated(removedPath, agent.BranchMeta{}); err != nil {
1438 t.Fatalf("SaveBranchMetaPreserveUpdated removed: %v", err)
1439 }
1440
1441 removedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: removedPath, Label: "removed", Sink: event.Discard})
1442 removedCtrl.SetMode(true, false)
1443 removedCtrl.SetToolApprovalMode(control.ToolApprovalAuto)
1444 removedCtrl.SetGoal("keep this profile")
1445
1446 app := NewApp()
1447 keep := &WorkspaceTab{
1448 ID: "keep",
1449 Scope: "global",
1450 WorkspaceRoot: root,
1451 SessionPath: keepPath,
1452 Ready: true,
1453 disabledMCP: map[string]ServerView{},
1454 }
1455 removed := &WorkspaceTab{
1456 ID: "removed",
1457 Scope: "global",
1458 WorkspaceRoot: root,
1459 SessionPath: removedPath,
1460 Ctrl: removedCtrl,
1461 Ready: true,
1462 mode: "plan",
1463 toolApprovalMode: control.ToolApprovalAuto,
1464 sink: &tabEventSink{tabID: "removed", app: app},
1465 disabledMCP: map[string]ServerView{},
1466 }
1467 app.tabs[keep.ID] = keep
1468 app.tabs[removed.ID] = removed
1469 app.tabOrder = []string{keep.ID, removed.ID}
1470 app.activeTabID = removed.ID
1471
1472 if _, err := app.keepOnlyVisibleTab(keep.ID); err != nil {
1473 t.Fatalf("keepOnlyVisibleTab: %v", err)
1474 }
1475
1476 meta, ok, err := agent.LoadBranchMeta(removedPath)
1477 if err != nil || !ok {
1478 t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
1479 }
1480 if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard ||
1481 meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
1482 t.Fatalf("removed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/standard/plan/auto/goal",
1483 meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
1484 }
1485 }
1486
1487 func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
1488 isolateDesktopUserDirs(t)
1489 sessionPath := filepath.Join(desktopSessionDir(globalTabWorkspaceRoot()), "terminal-goal.jsonl")
1490 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
1491 t.Fatal(err)
1492 }
1493 writeHistoryTestSession(t, sessionPath, "terminal prompt")
1494 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
1495 TokenMode: "economy", Mode: "plan", ToolApprovalMode: control.ToolApprovalAuto, Goal: "stale terminal goal",
1496 }); err != nil {
1497 t.Fatal(err)
1498 }
1499 if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil {
1500 t.Fatal(err)
1501 }
1502 profile := loadTabSessionProfile(sessionPath)
1503 if profile.goal != "" || profile.tokenMode != "full" || profile.qualityFloor != control.QualityFloorStandard || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
1504 t.Fatalf("profile=%+v", profile)
1505 }
1506 tab := &WorkspaceTab{}
1507 applyTabSessionProfile(tab, profile)
1508 if currentTabTokenMode(tab) != boot.TokenModeFull {
1509 t.Fatal("legacy economy must fold to standard")
1510 }
1511 }
1512
1513 func TestLoadTabSessionProfileMissingApprovalDefaultsAsk(t *testing.T) {
1514 isolateDesktopUserDirs(t)
1515 sessionPath := filepath.Join(desktopSessionDir(globalTabWorkspaceRoot()), "legacy-missing-approval.jsonl")
1516 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
1517 t.Fatal(err)
1518 }
1519 writeHistoryTestSession(t, sessionPath, "legacy prompt")
1520 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{Mode: "normal"}); err != nil {
1521 t.Fatal(err)
1522 }
1523 if profile := loadTabSessionProfile(sessionPath); profile.toolApprovalMode != control.ToolApprovalAsk {
1524 t.Fatalf("legacy missing tool approval mode = %q, want ask", profile.toolApprovalMode)
1525 }
1526 }
1527
1528 func writeHistoryTestSession(t *testing.T, path, prompt string) {
1529 t.Helper()
1530 session := agent.NewSession("")
1531 session.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
1532 if err := session.Save(path); err != nil {
1533 t.Fatalf("Save %s: %v", path, err)
1534 }
1535 }
1536
1536 lines GO