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