package main import ( "encoding/json" "os" "path/filepath" "strings" "testing" "unicode/utf8" "reasonix/internal/agent" "reasonix/internal/boot" "reasonix/internal/control" "reasonix/internal/event" "reasonix/internal/provider" "reasonix/internal/store" "reasonix/internal/tool" ) func TestHistoryMessagesIncludeAssistantReasoning(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleUser, Content: "expanded prompt"}, {Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "thinking trace", WorkDurationMs: 24_000, ToolCalls: []provider.ToolCall{{ ID: "call_1", Name: "bash", Arguments: `{"command":"pwd"}`, }}, MemoryCitations: []provider.MemoryCitation{{ ID: "mem-1", Source: "Memory v5", Note: "use previous bash failure", Kind: "constraint", }}}, {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_1", Content: "tool output", ReasoningContent: "ignored by frontend filter"}, {Role: provider.RoleAssistant, ReasoningContent: "tool-call-only thinking"}, } got := historyMessages(msgs, func(content string) string { if content != "expanded prompt" { t.Fatalf("unexpected user content passed to resolver: %q", content) } return "display prompt" }) if len(got) != len(msgs) { t.Fatalf("history length = %d, want %d", len(got), len(msgs)) } if got[0].Content != "display prompt" { t.Fatalf("user display content = %q, want display prompt", got[0].Content) } if got[0].SubmitText != "expanded prompt" { t.Fatalf("user submit text = %q, want expanded prompt", got[0].SubmitText) } if got[1].Reasoning != "thinking trace" { t.Fatalf("assistant reasoning = %q, want thinking trace", got[1].Reasoning) } if got[1].WorkDurationMs != 24_000 { t.Fatalf("assistant work duration = %d, want 24000", got[1].WorkDurationMs) } if len(got[1].MemoryCitations) != 1 || got[1].MemoryCitations[0].Note != "use previous bash failure" { t.Fatalf("assistant memory citations not preserved: %+v", got[1].MemoryCitations) } if len(got[1].ToolCalls) != 1 || got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "bash" { t.Fatalf("assistant tool calls not preserved: %+v", got[1].ToolCalls) } if !got[1].ToolCalls[0].ArgumentsArchived || got[1].ToolCalls[0].Arguments != "" || got[1].ToolCalls[0].Subject != "pwd" { t.Fatalf("assistant tool call was not restored as lightweight metadata: %+v", got[1].ToolCalls[0]) } if got[2].ToolCallID != "call_1" || got[2].ToolName != "bash" || got[2].Content != "" || !got[2].ToolResultArchived { t.Fatalf("tool result details not preserved: %+v", got[2]) } if got[2].Reasoning != "" { t.Fatalf("non-assistant reasoning should stay hidden, got %q", got[2].Reasoning) } if got[3].Reasoning != "tool-call-only thinking" { t.Fatalf("empty-content assistant reasoning = %q, want tool-call-only thinking", got[3].Reasoning) } } func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) { raw := historyMemoryCompilerContract(t, "ship the refactor") msgs := []provider.Message{ {Role: provider.RoleUser, Content: raw}, {Role: provider.RoleAssistant, Content: "done"}, } got := historyMessages(msgs, control.StripComposePrefixes) if len(got) != 2 { t.Fatalf("history length = %d, want 2: %+v", len(got), got) } if got[0].Content != "ship the refactor" { t.Fatalf("visible user content = %q, want source_event", got[0].Content) } if got[0].SubmitText != "" { t.Fatalf("raw Memory v5 contract should not be replay submitText, got %q", got[0].SubmitText) } assertNoHistoryMemoryContract(t, got[0].Content) } func TestHistoryMessagesRestoreCompiledSkillInvocationWithoutContract(t *testing.T) { raw := historyMemoryCompilerContract(t, "/reasonix-develop ship the refactor") msgs := []provider.Message{{Role: provider.RoleUser, Content: raw}} got := historyMessages(msgs, func(string) string { return "ship the refactor" }) if len(got) != 1 { t.Fatalf("history length = %d, want 1: %+v", len(got), got) } if got[0].Content != "ship the refactor" || got[0].SubmitText != "/reasonix-develop ship the refactor" { t.Fatalf("compiled skill history = %+v", got[0]) } assertNoHistoryMemoryContract(t, got[0].Content) assertNoHistoryMemoryContract(t, got[0].SubmitText) } func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) { raw := "\nship the approval redesign\n\n\ncontinue implementation" msgs := []provider.Message{ {Role: provider.RoleUser, Content: raw}, {Role: provider.RoleAssistant, Content: "done"}, } got := historyMessages(msgs, control.StripComposePrefixes) if len(got) != 2 { t.Fatalf("history length = %d, want 2: %+v", len(got), got) } if got[0].Content != "continue implementation" { t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content) } if strings.Contains(got[0].Content, "") || strings.Contains(got[0].Content, "ship the approval redesign") { t.Fatalf("active-goal leaked into visible history content: %+v", got[0]) } } func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "first visible"}, {Role: provider.RoleAssistant, Content: "first answer"}, {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."}, {Role: provider.RoleAssistant, Content: "hidden continuation"}, {Role: provider.RoleUser, Content: "second visible"}, {Role: provider.RoleAssistant, Content: "second answer"}, } got := historyMessagesWithPlannerDisplays( msgs, func(content string) string { return content }, nil, map[int]int{1: 0, 5: 2}, ) var users []HistoryMessage for _, msg := range got { if msg.Role == "user" { users = append(users, msg) } } if len(users) != 2 { t.Fatalf("visible users = %d, want 2: %+v", len(users), got) } if users[0].CheckpointTurn == nil || *users[0].CheckpointTurn != 0 { t.Fatalf("first checkpoint turn = %v, want 0", users[0].CheckpointTurn) } if users[1].CheckpointTurn == nil || *users[1].CheckpointTurn != 2 { t.Fatalf("second checkpoint turn = %v, want 2", users[1].CheckpointTurn) } } func TestHistoryPageFromMessagesWindowsByUserTurn(t *testing.T) { messages := []HistoryMessage{ {Role: "notice", Content: "session restored"}, {Role: "user", Content: "first"}, {Role: "assistant", Content: "one"}, {Role: "tool", ToolName: "bash", Content: "tool one"}, {Role: "user", Content: "second"}, {Role: "assistant", Content: "two"}, {Role: "user", Content: "third"}, {Role: "assistant", Content: "three"}, } latest := historyPageFromMessages(messages, 0, 2) if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder { t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest) } if len(latest.Messages) != 4 || latest.Messages[0].Content != "second" || latest.Messages[3].Content != "three" { t.Fatalf("latest page messages = %+v, want second and third turns", latest.Messages) } older := historyPageFromMessages(messages, latest.StartTurn, 2) if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder { t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older) } if len(older.Messages) != 4 || older.Messages[0].Content != "session restored" || older.Messages[1].Content != "first" { t.Fatalf("older page messages = %+v, want prelude and first turn", older.Messages) } } func TestHistoryPageFromProviderMessagesWindowsVisibleUsers(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "first"}, {Role: provider.RoleAssistant, Content: "one"}, {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."}, {Role: provider.RoleAssistant, Content: "hidden continuation"}, {Role: provider.RoleUser, Content: "second"}, {Role: provider.RoleAssistant, Content: "two"}, {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\nupdate the plan"}, {Role: provider.RoleUser, Content: "third"}, {Role: provider.RoleAssistant, Content: "three"}, } latest := historyPageFromProviderMessages( msgs, func(content string) string { return content }, nil, map[int]int{1: 0, 5: 2, 8: 3}, 0, 2, ) if latest.StartTurn != 1 || latest.EndTurn != 3 || latest.TotalTurns != 3 || !latest.HasOlder { t.Fatalf("latest page metadata = %+v, want turns 1-3/3 hasOlder", latest) } if len(latest.Messages) != 5 { t.Fatalf("latest page length = %d, want 5: %+v", len(latest.Messages), latest.Messages) } if latest.Messages[0].Role != "user" || latest.Messages[0].Content != "second" { t.Fatalf("first latest message = %+v, want second user", latest.Messages[0]) } if latest.Messages[0].CheckpointTurn == nil || *latest.Messages[0].CheckpointTurn != 2 { t.Fatalf("second user checkpoint = %v, want 2", latest.Messages[0].CheckpointTurn) } if latest.Messages[2].Role != "notice" || !strings.Contains(latest.Messages[2].Content, "update the plan") { t.Fatalf("steer message = %+v, want notice in second turn window", latest.Messages[2]) } if latest.Messages[3].Role != "user" || latest.Messages[3].Content != "third" { t.Fatalf("third latest message = %+v, want third user", latest.Messages[3]) } if latest.Messages[3].CheckpointTurn == nil || *latest.Messages[3].CheckpointTurn != 3 { t.Fatalf("third user checkpoint = %v, want 3", latest.Messages[3].CheckpointTurn) } older := historyPageFromProviderMessages( msgs, func(content string) string { return content }, nil, map[int]int{1: 0, 5: 2, 8: 3}, latest.StartTurn, 2, ) if older.StartTurn != 0 || older.EndTurn != 1 || older.TotalTurns != 3 || older.HasOlder { t.Fatalf("older page metadata = %+v, want turns 0-1/3 no older", older) } if len(older.Messages) != 4 || older.Messages[0].Role != "system" || older.Messages[1].Content != "first" || older.Messages[3].Content != "hidden continuation" { t.Fatalf("older page messages = %+v, want prelude and first visible turn", older.Messages) } } func TestHistoryCheckpointTurnsSkipsHiddenUsers(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleUser, Content: "first visible"}, {Role: provider.RoleAssistant, Content: "ok"}, {Role: provider.RoleUser, Content: "Continue pursuing the active goal. If it is complete, provide the concise final result."}, {Role: provider.RoleUser, Content: "second visible"}, } got := historyCheckpointTurns( msgs, func(content string) string { return content }, map[int]int{0: 0, 2: 1, 3: 2}, ) if len(got) != 2 || got[0] != 0 || got[1] != 2 { t.Fatalf("checkpoint turns = %v, want [0 2]", got) } } func TestHistoryForTabRestoresPlannerDisplayAfterReload(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") handoff := strings.Join([]string{ "# Reasonix executor handoff", "", "You are the executor now.", "", "Original task:", "fix the sandbox reload bug", "", "Planner output:", "inspect settings rebuild and preserve planner display", "", "Executor instructions:", "- apply the fix", }, "\n") sess := agent.NewSession("system") sess.Add(provider.Message{Role: provider.RoleUser, Content: handoff}) sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "executor kept working"}) ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard) ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: path, Sink: event.Discard}) if err := recordSessionDisplay(dir, path, handoff, "fix the sandbox reload bug"); err != nil { t.Fatalf("recordSessionDisplay: %v", err) } app := &App{ tabs: map[string]*WorkspaceTab{}, activeTabID: "planner_tab", } tab := &WorkspaceTab{ID: "planner_tab", Scope: "global", Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}} tab.sink = &tabEventSink{tabID: tab.ID, app: app} app.tabs[tab.ID] = tab tab.sink.Emit(event.Event{Kind: event.TurnStarted}) tab.sink.Emit(event.Event{Kind: event.Phase, Text: "deepseek-v4-pro · planning", Source: event.UsageSourcePlanner}) tab.sink.Emit(event.Event{Kind: event.Reasoning, Text: "planner thinking\n", Source: event.UsageSourcePlanner}) tab.sink.Emit(event.Event{Kind: event.Text, Text: "planner visible plan", Source: event.UsageSourcePlanner}) tab.sink.Emit(event.Event{Kind: event.Message, Text: "planner visible plan", Reasoning: "planner thinking\n", Source: event.UsageSourcePlanner}) tab.sink.Emit(event.Event{Kind: event.TurnStarted}) tab.sink.Emit(event.Event{Kind: event.TurnDone}) waitForAutosaveIdle(t, tab) got := app.HistoryForTab(tab.ID) if len(got) != 5 { t.Fatalf("history length = %d, want user + planner phase + planner answer + executor answer (plus system skipped later by UI): %+v", len(got), got) } if got[1].Content != "fix the sandbox reload bug" { t.Fatalf("user display content = %q, want original prompt", got[1].Content) } if got[2].Role != "phase" || !strings.Contains(got[2].Content, "planning") { t.Fatalf("planner phase missing after reload: %+v", got) } if got[3].Role != "assistant" || got[3].Content != "planner visible plan" || got[3].Reasoning != "planner thinking\n" { t.Fatalf("planner assistant display missing after reload: %+v", got[3]) } if got[4].Role != "assistant" || got[4].Content != "executor kept working" { t.Fatalf("executor answer missing after reload: %+v", got[4]) } } func TestHistoryForTabUsesPinnedSessionBeforeControllerReady(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "pending-controller.jsonl") writeHistoryTestSession(t, path, "warm prompt") app := NewApp() tab := &WorkspaceTab{ ID: "pending", Scope: "global", WorkspaceRoot: root, SessionPath: path, Ready: false, disabledMCP: map[string]ServerView{}, } app.tabs[tab.ID] = tab app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID got := app.HistoryForTab(tab.ID) if len(got) != 1 || got[0].Role != "user" || got[0].Content != "warm prompt" { t.Fatalf("pending controller history = %+v, want warm prompt", got) } } func historyMemoryCompilerContract(t *testing.T, sourceEvent string) string { t.Helper() body, err := json.Marshal(map[string]any{ "type": "memory_v5_execution_contract", "planner_ir": map[string]any{ "source_event": sourceEvent, }, }) if err != nil { t.Fatal(err) } return "\n" + string(body) + "\n" } func assertNoHistoryMemoryContract(t *testing.T, text string) { t.Helper() if strings.Contains(text, "") || strings.Contains(text, "") || strings.Contains(text, "memory_v5_execution_contract") || strings.Contains(text, "planner_ir") { t.Fatalf("history leaked Memory v5 contract content: %q", text) } } func TestHistoryMessagesArchiveCompletedToolPayloads(t *testing.T) { largeArgs := `{"command":"` + strings.Repeat("printf x;", 300) + `"}` largeOutput := strings.Repeat("line of output\n", 600) msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "call_large", Name: "bash", Arguments: largeArgs, }}}, {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_large", Content: largeOutput}, } got := historyMessages(msgs, func(content string) string { return content }) if len(got) != 2 { t.Fatalf("history length = %d, want 2", len(got)) } call := got[0].ToolCalls[0] if !call.ArgumentsArchived { t.Fatalf("tool arguments were not marked archived: %+v", call) } if call.Arguments != "" { t.Fatalf("archived tool arguments should be omitted from initial history, got %d bytes", len(call.Arguments)) } if call.Subject == "" { t.Fatalf("archived tool call should keep a collapsed subject: %+v", call) } if call.Summary == "" { t.Fatalf("archived tool call should keep a collapsed summary: %+v", call) } result := got[1] if !result.ToolResultArchived { t.Fatalf("tool result was not marked archived: %+v", result) } if result.Content != "" { t.Fatalf("archived successful tool output should be omitted from initial history, got %d bytes", len(result.Content)) } encoded, err := json.Marshal(got) if err != nil { t.Fatal(err) } if strings.Contains(string(encoded), largeArgs) || strings.Contains(string(encoded), largeOutput) { t.Fatalf("initial history JSON still contains large args/output: %d bytes", len(encoded)) } } func TestHistoryMessagesKeepRunSkillSubjectWhenArchived(t *testing.T) { args := `{"name":"code-reviewer","arguments":"review this branch"}` msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "call_skill", Name: "run_skill", Arguments: args, }}}, {Role: provider.RoleTool, Name: "run_skill", ToolCallID: "call_skill", Content: "Skill completed"}, } got := historyMessages(msgs, func(content string) string { return content }) if len(got) != 2 { t.Fatalf("history length = %d, want 2", len(got)) } call := got[0].ToolCalls[0] if !call.ArgumentsArchived || call.Arguments != "" { t.Fatalf("run_skill arguments should be archived after completion: %+v", call) } if call.Subject != "code-reviewer" { t.Fatalf("run_skill subject = %q, want code-reviewer", call.Subject) } } func TestHistoryMessagesKeepToolFileDiffMetadata(t *testing.T) { diff := "@@ -27 +27 @@\n-func save():\n+func save_file():\n" msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "edit", Name: "edit_file", Arguments: `{"path":"settings/settings_IO.gd","old_string":"func save():","new_string":"func save_file():"}`, Diff: diff, Added: 1, Removed: 1, }}}, {Role: provider.RoleTool, Name: "edit_file", ToolCallID: "edit", Content: "edited settings/settings_IO.gd"}, } got := historyMessages(msgs, func(content string) string { return content }) call := got[0].ToolCalls[0] if call.Diff != diff || call.Added != 1 || call.Removed != 1 { t.Fatalf("history tool diff metadata = diff:%q +%d -%d", call.Diff, call.Added, call.Removed) } if !call.ArgumentsArchived || call.Arguments != "" { t.Fatalf("tool arguments should still be archived: %+v", call) } } func TestHistoryMessagesKeepBoundedToolErrors(t *testing.T) { largeError := "error: " + strings.Repeat("permission denied ", 400) msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "call_error", Name: "bash", Arguments: `{"command":"rm protected"}`, }}}, {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_error", Content: largeError}, } got := historyMessages(msgs, func(content string) string { return content }) result := got[1] if result.ToolResultError == "" { t.Fatalf("failed tool result should keep an error preview: %+v", result) } if result.Content != result.ToolResultError { t.Fatalf("tool result content and error preview diverged: content=%q error=%q", result.Content, result.ToolResultError) } if len(result.Content) >= len(largeError) { t.Fatalf("failed tool result preview was not bounded: got %d want < %d", len(result.Content), len(largeError)) } if !strings.HasPrefix(result.Content, "error: permission denied") { t.Fatalf("failed tool result preview lost useful prefix: %q", result.Content[:min(len(result.Content), 80)]) } if !result.ToolResultArchived { t.Fatalf("bounded failed tool result should still be marked archived for on-demand full data: %+v", result) } } func TestHistoryMessagesClipToolErrorsAtUTF8Boundary(t *testing.T) { largeError := "error: " + strings.Repeat("权限不足", 1000) msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "call_unicode_error", Name: "bash", Arguments: `{"command":"rm 受保护文件"}`, }}}, {Role: provider.RoleTool, Name: "bash", ToolCallID: "call_unicode_error", Content: largeError}, } got := historyMessages(msgs, func(content string) string { return content }) result := got[1] if result.ToolResultError == "" { t.Fatalf("failed tool result should keep an error preview: %+v", result) } if !utf8.ValidString(result.ToolResultError) { t.Fatalf("failed tool result preview is not valid UTF-8: %q", result.ToolResultError) } if len(result.ToolResultError) >= len(largeError) { t.Fatalf("failed tool result preview was not bounded: got %d want < %d", len(result.ToolResultError), len(largeError)) } } func TestHistoryMessagesKeepTodoWriteArguments(t *testing.T) { args := `{"todos":[{"content":"A","status":"in_progress"}]}` msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ ID: "todo_1", Name: "todo_write", Arguments: args, }}}, {Role: provider.RoleTool, Name: "todo_write", ToolCallID: "todo_1", Content: "Todos updated"}, } got := historyMessages(msgs, func(content string) string { return content }) call := got[0].ToolCalls[0] if call.ArgumentsArchived { t.Fatalf("todo_write arguments must remain available for restored todo panel: %+v", call) } if call.Arguments != args { t.Fatalf("todo_write arguments = %q, want %q", call.Arguments, args) } } func TestHistoryMessagesPreserveUnaddressableToolPayloads(t *testing.T) { args := `{"command":"legacy"}` output := "legacy output\n" + strings.Repeat("detail\n", 8) msgs := []provider.Message{ {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ Name: "bash", Arguments: args, }}}, {Role: provider.RoleTool, Name: "bash", Content: output}, } got := historyMessages(msgs, func(content string) string { return content }) call := got[0].ToolCalls[0] if call.ArgumentsArchived { t.Fatalf("tool call without an id cannot be archived for later lookup: %+v", call) } if call.Arguments != args { t.Fatalf("tool call without an id should keep args, got %q", call.Arguments) } result := got[1] if result.ToolResultArchived { t.Fatalf("tool result without an id cannot be archived for later lookup: %+v", result) } if result.Content != output { t.Fatalf("tool result without an id should keep output, got %q", result.Content) } } func TestPreviewSessionMessagesLoadsWithoutResuming(t *testing.T) { dir := t.TempDir() session := agent.NewSession("") session.Add(provider.Message{Role: provider.RoleUser, Content: "show history"}) session.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "saved reasoning"}) path := filepath.Join(dir, "session.jsonl") if err := session.Save(path); err != nil { t.Fatalf("Save: %v", err) } got, err := previewSessionMessages(dir, path) if err != nil { t.Fatalf("previewSessionMessages: %v", err) } if len(got) != 2 { t.Fatalf("preview history length = %d, want 2", len(got)) } if got[1].Reasoning != "saved reasoning" { t.Fatalf("preview reasoning = %q, want saved reasoning", got[1].Reasoning) } } func TestPreviewSessionMessagesIncludesProcessEvents(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "events.jsonl") body := strings.Join([]string{ `{"kind":"phase","text":"Preparing context"}`, `{"kind":"notice","level":"warn","text":"Network changed"}`, `{"kind":"compaction_started","compaction":{"trigger":"manual"}}`, `{"kind":"compaction_done","compaction":{"trigger":"manual","messages":6,"summary":"Kept the current task.","archive":"/tmp/archive.jsonl"}}`, `{"type":"user.message","text":"hello"}`, `{"type":"model.final","content":"hi","reasoningContent":"thinking"}`, }, "\n") + "\n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } got, err := previewSessionMessages(dir, path) if err != nil { t.Fatalf("previewSessionMessages: %v", err) } if len(got) != 6 { t.Fatalf("preview history length = %d, want 6: %+v", len(got), got) } if got[0].Role != "phase" || got[0].Content != "Preparing context" { t.Fatalf("phase event not preserved: %+v", got[0]) } if got[1].Role != "notice" || got[1].Level != "warn" || got[1].Content != "Network changed" { t.Fatalf("notice event not preserved: %+v", got[1]) } if got[2].Role != "compaction" || !got[2].Pending || got[2].Trigger != "manual" { t.Fatalf("pending compaction event not preserved: %+v", got[2]) } 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" { t.Fatalf("finished compaction event not preserved: %+v", got[3]) } if got[4].Role != "user" || got[5].Reasoning != "thinking" { t.Fatalf("conversation events not preserved: %+v", got[4:]) } } func TestResumeSessionForTabTargetsSpecifiedTab(t *testing.T) { isolateDesktopUserDirs(t) dir := desktopSessionDir(globalTabWorkspaceRoot()) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } activePath := filepath.Join(dir, "active.jsonl") inactivePath := filepath.Join(dir, "inactive.jsonl") targetPath := filepath.Join(dir, "target.jsonl") writeHistoryTestSession(t, activePath, "active prompt") writeHistoryTestSession(t, inactivePath, "inactive prompt") writeHistoryTestSession(t, targetPath, "target prompt") activeExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard) inactiveExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard) activeCtrl := control.New(control.Options{Executor: activeExec, SessionDir: dir, SessionPath: activePath, Label: "active"}) inactiveCtrl := control.New(control.Options{Executor: inactiveExec, SessionDir: dir, SessionPath: inactivePath, Label: "inactive"}) defer activeCtrl.Close() defer inactiveCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{ "active": { ID: "active", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), Ctrl: activeCtrl, Ready: true, sink: &tabEventSink{tabID: "active"}, disabledMCP: map[string]ServerView{}, }, "inactive": { ID: "inactive", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), SessionPath: inactivePath, Ctrl: inactiveCtrl, Ready: true, sink: &tabEventSink{tabID: "inactive"}, disabledMCP: map[string]ServerView{}, }, }, tabOrder: []string{"active", "inactive"}, activeTabID: "active", } got, err := app.ResumeSessionForTab("inactive", targetPath) if err != nil { t.Fatalf("ResumeSessionForTab: %v", err) } if activeCtrl.SessionPath() != activePath { t.Fatalf("active tab session path = %q, want %q", activeCtrl.SessionPath(), activePath) } if inactiveCtrl.SessionPath() != inactivePath { t.Fatalf("original inactive controller session path = %q, want %q", inactiveCtrl.SessionPath(), inactivePath) } if app.tabs["inactive"].Ctrl == inactiveCtrl { t.Fatal("resume to a different sessionPath mutated the existing controller in place") } if app.tabs["inactive"].Ctrl.SessionPath() != targetPath { t.Fatalf("inactive tab session path = %q, want %q", app.tabs["inactive"].Ctrl.SessionPath(), targetPath) } f := loadTabsFile() var savedInactive string for _, entry := range f.Tabs { if entry.ID == "inactive" { savedInactive = entry.SessionPath break } } if filepath.Clean(savedInactive) != filepath.Clean(targetPath) { t.Fatalf("saved inactive session path = %q, want %q", savedInactive, targetPath) } if len(got) != 1 || got[0].Content != "target prompt" { t.Fatalf("resumed history = %+v, want target prompt", got) } } func TestResumeSessionForTabDetachesRunningRuntimeForDifferentSessionPath(t *testing.T) { isolateDesktopUserDirs(t) dir := desktopSessionDir(globalTabWorkspaceRoot()) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } topicID := "topic_same" sessionA := filepath.Join(dir, "session-a.jsonl") sessionB := filepath.Join(dir, "session-b.jsonl") writeHistoryTestSession(t, sessionA, "session A prompt") writeHistoryTestSession(t, sessionB, "session B prompt") runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} ctrlA := control.New(control.Options{ Runner: runner, SessionDir: dir, SessionPath: sessionA, Label: "session-a", Sink: event.Discard, }) defer ctrlA.Close() app := NewApp() tab := &WorkspaceTab{ ID: "topic-tab", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: topicID, TopicTitle: "Same topic", SessionPath: sessionA, Ctrl: ctrlA, Ready: true, sink: &tabEventSink{tabID: "topic-tab", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs[tab.ID] = tab app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID ctrlA.Submit("keep running") <-runner.started got, err := app.ResumeSessionForTab(tab.ID, sessionB) if err != nil { t.Fatalf("ResumeSessionForTab: %v", err) } if !ctrlA.Running() { t.Fatal("session A controller was cancelled while resuming session B") } if ctrlA.SessionPath() != sessionA { t.Fatalf("session A controller path = %q, want %q", ctrlA.SessionPath(), sessionA) } detached := app.detachedSessions[sessionRuntimeKey(sessionA)] if detached == nil || detached.Ctrl != ctrlA { t.Fatalf("session A runtime was not detached: %+v", detached) } if detached.ID == tab.ID { t.Fatalf("detached runtime kept visible tab id %q", detached.ID) } if detached.sink == nil { t.Fatal("detached runtime lost its event sink") } if detached.sink.tabID == tab.ID { t.Fatalf("detached sink tab id = %q, want non-visible id", detached.sink.tabID) } if app.tabs[tab.ID].Ctrl == ctrlA { t.Fatal("visible tab still points at session A runtime after resuming session B") } if gotPath := app.tabs[tab.ID].Ctrl.SessionPath(); gotPath != sessionB { t.Fatalf("visible tab session path = %q, want %q", gotPath, sessionB) } if len(got) != 1 || got[0].Content != "session B prompt" { t.Fatalf("resumed history = %+v, want session B prompt", got) } visible := app.tabs[tab.ID] detached.sink.Emit(event.Event{Kind: event.TurnStarted}) if visible.ActivityStatus != "" { t.Fatalf("detached runtime event changed visible tab status to %q", visible.ActivityStatus) } detached.sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ Name: "read_file", Args: `{"path":"detached.go","offset":3,"limit":7}`, Output: "package main", }}) if got := detached.telemetrySnapshot().ReadFiles; len(got) != 1 || got[0].Path != "detached.go" || got[0].Offset != 3 || got[0].Limit != 7 { t.Fatalf("detached runtime read telemetry = %+v", got) } if got := visible.telemetrySnapshot().ReadFiles; len(got) != 0 { t.Fatalf("detached runtime read telemetry was recorded on visible tab: %+v", got) } detached.sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{PromptTokens: 42}}) detached.sink.Emit(event.Event{Kind: event.TurnDone}) if detached.usageTelemetry.PromptTokens != 42 { t.Fatalf("detached runtime usage was not recorded on detached tab: %+v", detached.usageTelemetry) } if detached.usageTelemetry.RequestCount != 1 { t.Fatalf("detached runtime request count = %d, want 1", detached.usageTelemetry.RequestCount) } if visible.usageTelemetry.PromptTokens != 0 { t.Fatalf("detached runtime usage was recorded on visible tab: %+v", visible.usageTelemetry) } if visible.saveAgain || visible.saving { t.Fatalf("detached runtime scheduled visible tab snapshot: saving=%v saveAgain=%v", visible.saving, visible.saveAgain) } close(runner.release) waitNotRunning(t, ctrlA) } func TestRebindTabToLoadedSessionReusesPreloadedTranscript(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } currentPath := filepath.Join(dir, "current.jsonl") targetPath := filepath.Join(dir, "target.jsonl") writeHistoryTestSession(t, currentPath, "current prompt") writeHistoryTestSession(t, targetPath, "target prompt") loaded, err := agent.LoadSession(targetPath) if err != nil { t.Fatalf("LoadSession: %v", err) } if err := os.Remove(targetPath); err != nil { t.Fatalf("remove target session: %v", err) } ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard}) defer ctrl.Close() app := NewApp() tab := &WorkspaceTab{ ID: "tab", Scope: "global", WorkspaceRoot: root, SessionPath: currentPath, Ctrl: ctrl, Ready: true, sink: &tabEventSink{tabID: "tab", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs[tab.ID] = tab app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil { t.Fatalf("rebindTabToLoadedSessionPath: %v", err) } got := app.HistoryForTab(tab.ID) if len(got) != 1 || got[0].Content != "target prompt" { t.Fatalf("rebound history = %+v, want target prompt", got) } if gotPath := app.tabs[tab.ID].Ctrl.SessionPath(); gotPath != targetPath { t.Fatalf("rebound session path = %q, want %q", gotPath, targetPath) } } func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } currentPath := filepath.Join(dir, "current.jsonl") targetPath := filepath.Join(dir, "target.jsonl") writeHistoryTestSession(t, currentPath, "current prompt") writeHistoryTestSession(t, targetPath, "target prompt") if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{ TokenMode: boot.TokenModeFull, Mode: "yolo", ToolApprovalMode: control.ToolApprovalYolo, }); err != nil { t.Fatalf("SaveBranchMetaPreserveUpdated target: %v", err) } loaded, err := agent.LoadSession(targetPath) if err != nil { t.Fatalf("LoadSession: %v", err) } ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard}) ctrl.SetMode(true, false) ctrl.SetToolApprovalMode(control.ToolApprovalAuto) defer ctrl.Close() app := NewApp() tab := &WorkspaceTab{ ID: "tab", Scope: "global", WorkspaceRoot: root, SessionPath: currentPath, Ctrl: ctrl, Ready: true, tokenMode: boot.TokenModeEconomy, mode: "plan", toolApprovalMode: control.ToolApprovalAuto, sink: &tabEventSink{tabID: "tab", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs[tab.ID] = tab app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil { t.Fatalf("rebindTabToLoadedSessionPath: %v", err) } currentMeta, ok, err := agent.LoadBranchMeta(currentPath) if err != nil || !ok { t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err) } if currentMeta.TokenMode != boot.TokenModeEconomy || currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto { t.Fatalf("current session profile = token:%q mode:%q approval:%q, want economy/plan/auto", currentMeta.TokenMode, currentMeta.Mode, currentMeta.ToolApprovalMode) } if got := currentTabTokenMode(tab); got != boot.TokenModeFull { t.Fatalf("rebound token mode = %q, want full", got) } if got := currentTabMode(tab); got != "yolo" { t.Fatalf("rebound mode = %q, want yolo", got) } if got := currentTabToolApprovalMode(tab); got != control.ToolApprovalYolo { t.Fatalf("rebound tool approval = %q, want yolo", got) } } func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } currentPath := filepath.Join(dir, "profile.jsonl") otherPath := filepath.Join(dir, "other.jsonl") writeHistoryTestSession(t, currentPath, "profile prompt") writeHistoryTestSession(t, otherPath, "other prompt") if err := agent.SaveBranchMetaPreserveUpdated(currentPath, agent.BranchMeta{}); err != nil { t.Fatalf("SaveBranchMetaPreserveUpdated current: %v", err) } ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "profile", Sink: event.Discard}) ctrl.SetMode(true, false) ctrl.SetToolApprovalMode(control.ToolApprovalAuto) ctrl.SetGoal("finish the review") app := NewApp() tab := &WorkspaceTab{ ID: "profile", Scope: "global", WorkspaceRoot: root, SessionPath: currentPath, Ctrl: ctrl, Ready: true, tokenMode: boot.TokenModeEconomy, mode: "plan", toolApprovalMode: control.ToolApprovalAuto, sink: &tabEventSink{tabID: "profile", app: app}, disabledMCP: map[string]ServerView{}, } other := &WorkspaceTab{ ID: "other", Scope: "global", WorkspaceRoot: root, SessionPath: otherPath, Ready: true, disabledMCP: map[string]ServerView{}, } app.tabs[tab.ID] = tab app.tabs[other.ID] = other app.tabOrder = []string{tab.ID, other.ID} app.activeTabID = tab.ID if err := app.CloseTab(tab.ID); err != nil { t.Fatalf("CloseTab: %v", err) } meta, ok, err := agent.LoadBranchMeta(currentPath) if err != nil || !ok { t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err) } if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" { t.Fatalf("closed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal", meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal) } } func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } keepPath := filepath.Join(dir, "keep.jsonl") removedPath := filepath.Join(dir, "removed.jsonl") writeHistoryTestSession(t, keepPath, "keep prompt") writeHistoryTestSession(t, removedPath, "removed prompt") if err := agent.SaveBranchMetaPreserveUpdated(removedPath, agent.BranchMeta{}); err != nil { t.Fatalf("SaveBranchMetaPreserveUpdated removed: %v", err) } removedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: removedPath, Label: "removed", Sink: event.Discard}) removedCtrl.SetMode(true, false) removedCtrl.SetToolApprovalMode(control.ToolApprovalAuto) removedCtrl.SetGoal("keep this profile") app := NewApp() keep := &WorkspaceTab{ ID: "keep", Scope: "global", WorkspaceRoot: root, SessionPath: keepPath, Ready: true, disabledMCP: map[string]ServerView{}, } removed := &WorkspaceTab{ ID: "removed", Scope: "global", WorkspaceRoot: root, SessionPath: removedPath, Ctrl: removedCtrl, Ready: true, tokenMode: boot.TokenModeEconomy, mode: "plan", toolApprovalMode: control.ToolApprovalAuto, sink: &tabEventSink{tabID: "removed", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs[keep.ID] = keep app.tabs[removed.ID] = removed app.tabOrder = []string{keep.ID, removed.ID} app.activeTabID = removed.ID if _, err := app.keepOnlyVisibleTab(keep.ID); err != nil { t.Fatalf("keepOnlyVisibleTab: %v", err) } meta, ok, err := agent.LoadBranchMeta(removedPath) if err != nil || !ok { t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err) } if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" { t.Fatalf("removed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal", meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal) } } func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "terminal-goal.jsonl") writeHistoryTestSession(t, sessionPath, "terminal prompt") if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{ TokenMode: boot.TokenModeEconomy, Mode: "plan", ToolApprovalMode: control.ToolApprovalAuto, Goal: "stale terminal goal", }); err != nil { t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err) } if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil { t.Fatalf("write goal state: %v", err) } profile := loadTabSessionProfile(sessionPath) if profile.goal != "" { t.Fatalf("loaded profile goal = %q, want terminal goal ignored", profile.goal) } if profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto { t.Fatalf("loaded profile = token:%q mode:%q approval:%q, want economy/plan/auto", profile.tokenMode, profile.mode, profile.toolApprovalMode) } } func TestLoadTabSessionProfileMissingApprovalDefaultsAsk(t *testing.T) { isolateDesktopUserDirs(t) root := globalTabWorkspaceRoot() dir := desktopSessionDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "legacy-missing-approval.jsonl") writeHistoryTestSession(t, sessionPath, "legacy prompt") if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{Mode: "normal"}); err != nil { t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err) } profile := loadTabSessionProfile(sessionPath) if profile.toolApprovalMode != control.ToolApprovalAsk { t.Fatalf("legacy missing tool approval mode = %q, want ask", profile.toolApprovalMode) } } func writeHistoryTestSession(t *testing.T, path, prompt string) { t.Helper() session := agent.NewSession("") session.Add(provider.Message{Role: provider.RoleUser, Content: prompt}) if err := session.Save(path); err != nil { t.Fatalf("Save %s: %v", path, err) } }