Files
2026-07-13 13:00:08 +08:00

1124 lines
42 KiB
Go

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 := "<active-goal>\nship the approval redesign\n</active-goal>\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, "<active-goal>") || 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 "<memory-compiler-execution>\n" + string(body) + "\n</memory-compiler-execution>"
}
func assertNoHistoryMemoryContract(t *testing.T, text string) {
t.Helper()
if strings.Contains(text, "<memory-compiler-execution>") ||
strings.Contains(text, "</memory-compiler-execution>") ||
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)
}
}