| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | tea "charm.land/bubbletea/v2" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/control" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/provider" |
| 16 | ) |
| 17 | |
| 18 | func newTestChatTUIWithMessages(t *testing.T, workspaceRoot string, msgs ...provider.Message) chatTUI { |
| 19 | t.Helper() |
| 20 | sess := agent.NewSession("system prompt should not export") |
| 21 | for _, msg := range msgs { |
| 22 | sess.Add(msg) |
| 23 | } |
| 24 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 25 | ctrl := control.New(control.Options{Executor: exec, WorkspaceRoot: workspaceRoot}) |
| 26 | return newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 27 | } |
| 28 | |
| 29 | func requireClipboardCommand(t *testing.T, cmd tea.Cmd, want string) { |
| 30 | t.Helper() |
| 31 | if cmd == nil { |
| 32 | t.Fatal("expected clipboard command, got nil") |
| 33 | } |
| 34 | t.Setenv("SSH_CONNECTION", "") |
| 35 | t.Setenv("SSH_CLIENT", "") |
| 36 | t.Setenv("SSH_TTY", "") |
| 37 | previous := writeNativeClipboardText |
| 38 | defer func() { writeNativeClipboardText = previous }() |
| 39 | var written string |
| 40 | writeNativeClipboardText = func(text string) error { |
| 41 | written = text |
| 42 | return nil |
| 43 | } |
| 44 | gotMsg, ok := cmd().(clipboardCopyMsg) |
| 45 | if !ok || gotMsg.text != want || written != want || gotMsg.err != nil || gotMsg.osc52 { |
| 46 | t.Fatalf("clipboard command = %#v, native write = %q, want %q", gotMsg, written, want) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | func sessionExportFiles(t *testing.T, dir string) []string { |
| 51 | t.Helper() |
| 52 | entries, err := os.ReadDir(dir) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | var exported []string |
| 57 | for _, entry := range entries { |
| 58 | if strings.HasPrefix(entry.Name(), "session-") && strings.HasSuffix(entry.Name(), ".md") { |
| 59 | exported = append(exported, filepath.Join(dir, entry.Name())) |
| 60 | } |
| 61 | } |
| 62 | return exported |
| 63 | } |
| 64 | |
| 65 | func TestSlashCopyDirectIndexUsesCurrentTurnNewestFirst(t *testing.T) { |
| 66 | m := newTestChatTUIWithMessages(t, "", |
| 67 | provider.Message{Role: provider.RoleUser, Content: "old prompt"}, |
| 68 | provider.Message{Role: provider.RoleAssistant, Content: "old answer"}, |
| 69 | provider.Message{Role: provider.RoleUser, Content: "current prompt"}, |
| 70 | provider.Message{Role: provider.RoleAssistant, Content: "first current answer"}, |
| 71 | provider.Message{Role: provider.RoleAssistant, Content: "..."}, |
| 72 | provider.Message{Role: provider.RoleTool, Content: "tool result"}, |
| 73 | provider.Message{Role: provider.RoleAssistant, Content: "second current answer"}, |
| 74 | ) |
| 75 | |
| 76 | requireClipboardCommand(t, m.runCopyCommand("/copy 1"), "second current answer") |
| 77 | requireClipboardCommand(t, m.runCopyCommand("/copy 2"), "first current answer") |
| 78 | if cmd := m.runCopyCommand("/copy 3"); cmd != nil { |
| 79 | t.Fatalf("out-of-range /copy should not return a command, got %#v", cmd()) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestSlashCopyPickerCopiesSelectedAssistantMessage(t *testing.T) { |
| 84 | m := newTestChatTUIWithMessages(t, "", |
| 85 | provider.Message{Role: provider.RoleUser, Content: "current prompt"}, |
| 86 | provider.Message{Role: provider.RoleAssistant, Content: "first current answer"}, |
| 87 | provider.Message{Role: provider.RoleAssistant, Content: "second current answer"}, |
| 88 | ) |
| 89 | |
| 90 | if cmd := m.runCopyCommand("/copy"); cmd != nil { |
| 91 | t.Fatalf("bare /copy should open picker without command, got %#v", cmd()) |
| 92 | } |
| 93 | if m.copyPick == nil { |
| 94 | t.Fatal("bare /copy did not open the picker") |
| 95 | } |
| 96 | if got, want := m.copyPick.parts, []string{"second current answer", "first current answer"}; !reflect.DeepEqual(got, want) { |
| 97 | t.Fatalf("picker parts = %#v, want %#v", got, want) |
| 98 | } |
| 99 | |
| 100 | next, _ := m.handleCopyPickerKey(tea.KeyPressMsg{Code: tea.KeyDown}) |
| 101 | m = next.(chatTUI) |
| 102 | next, cmd := m.handleCopyPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter}) |
| 103 | m = next.(chatTUI) |
| 104 | |
| 105 | if m.copyPick != nil { |
| 106 | t.Fatal("picker should close after copying") |
| 107 | } |
| 108 | requireClipboardCommand(t, cmd, "first current answer") |
| 109 | } |
| 110 | |
| 111 | func TestSlashExportFiltersInternalAndReferencedContext(t *testing.T) { |
| 112 | dir := t.TempDir() |
| 113 | expandedReference := control.PlanModeMarker + "\n\n" + |
| 114 | "Referenced context:\n\n" + |
| 115 | "<file path=\"auth_private.go\">\nconst hiddenReference = true\n</file>\n\n" + |
| 116 | "please explain @auth_private.go" |
| 117 | m := newTestChatTUIWithMessages(t, dir, |
| 118 | provider.Message{Role: provider.RoleUser, Content: expandedReference}, |
| 119 | provider.Message{Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\ninternal steer should not export"}, |
| 120 | provider.Message{ |
| 121 | Role: provider.RoleAssistant, |
| 122 | Content: "visible answer", |
| 123 | ReasoningContent: "private thinking should not export", |
| 124 | ToolCalls: []provider.ToolCall{{ |
| 125 | ID: "call_1", |
| 126 | Name: "read_file", |
| 127 | Arguments: `{"path":"private-tool-input.txt"}`, |
| 128 | }}, |
| 129 | }, |
| 130 | provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "tool output should not export"}, |
| 131 | ) |
| 132 | |
| 133 | m.runExportCommand("/export") |
| 134 | |
| 135 | exported := sessionExportFiles(t, dir) |
| 136 | if len(exported) != 1 { |
| 137 | t.Fatalf("exported files = %v, want one session markdown file", exported) |
| 138 | } |
| 139 | data, err := os.ReadFile(exported[0]) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | got := string(data) |
| 144 | for _, want := range []string{ |
| 145 | "# reasonix session", |
| 146 | "## User", |
| 147 | "please explain @auth_private.go", |
| 148 | "## Assistant", |
| 149 | "visible answer", |
| 150 | } { |
| 151 | if !strings.Contains(got, want) { |
| 152 | t.Fatalf("export missing %q:\n%s", want, got) |
| 153 | } |
| 154 | } |
| 155 | for _, unwanted := range []string{ |
| 156 | "system prompt should not export", |
| 157 | "Referenced context:", |
| 158 | "<file path=", |
| 159 | "hiddenReference", |
| 160 | "internal steer should not export", |
| 161 | "private thinking should not export", |
| 162 | "private-tool-input.txt", |
| 163 | "tool output should not export", |
| 164 | } { |
| 165 | if strings.Contains(got, unwanted) { |
| 166 | t.Fatalf("export leaked %q:\n%s", unwanted, got) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestSlashExportDoesNotWriteEmptyMarkdown(t *testing.T) { |
| 172 | tests := []struct { |
| 173 | name string |
| 174 | msgs []provider.Message |
| 175 | }{ |
| 176 | { |
| 177 | name: "system only", |
| 178 | }, |
| 179 | { |
| 180 | name: "filtered only", |
| 181 | msgs: []provider.Message{ |
| 182 | {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\ninternal steer should not export"}, |
| 183 | {Role: provider.RoleUser, Content: control.PlanModeMarker}, |
| 184 | {Role: provider.RoleAssistant, Content: " "}, |
| 185 | {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "tool output should not export"}, |
| 186 | }, |
| 187 | }, |
| 188 | } |
| 189 | for _, tt := range tests { |
| 190 | t.Run(tt.name, func(t *testing.T) { |
| 191 | dir := t.TempDir() |
| 192 | m := newTestChatTUIWithMessages(t, dir, tt.msgs...) |
| 193 | |
| 194 | m.runExportCommand("/export") |
| 195 | |
| 196 | if exported := sessionExportFiles(t, dir); len(exported) != 0 { |
| 197 | t.Fatalf("exported files = %v, want none", exported) |
| 198 | } |
| 199 | if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "no messages to export") { |
| 200 | t.Fatalf("missing empty-export notice in transcript:\n%s", out) |
| 201 | } |
| 202 | }) |
| 203 | } |
| 204 | } |
| 205 |