返回 DeepSeek-Reasonix
copy_export_test.go
根目录 / internal / cli / copy_export_test.go
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
205 lines GO