返回 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 := newOwnedTestController(t, 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, Origin: provider.MessageOriginHost, Content: "<pinned_context_revision>private pinned body</pinned_context_revision>"},
119 provider.Message{Role: provider.RoleUser, Content: expandedReference},
120 provider.Message{Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\ninternal steer should not export"},
121 provider.Message{
122 Role: provider.RoleAssistant,
123 Content: "visible answer",
124 ReasoningContent: "private thinking should not export",
125 ToolCalls: []provider.ToolCall{{
126 ID: "call_1",
127 Name: "read_file",
128 Arguments: `{"path":"private-tool-input.txt"}`,
129 }},
130 },
131 provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "tool output should not export"},
132 )
133
134 m.runExportCommand("/export")
135
136 exported := sessionExportFiles(t, dir)
137 if len(exported) != 1 {
138 t.Fatalf("exported files = %v, want one session markdown file", exported)
139 }
140 data, err := os.ReadFile(exported[0])
141 if err != nil {
142 t.Fatal(err)
143 }
144 got := string(data)
145 for _, want := range []string{
146 "# reasonix session",
147 "## User",
148 "please explain @auth_private.go",
149 "## Assistant",
150 "visible answer",
151 } {
152 if !strings.Contains(got, want) {
153 t.Fatalf("export missing %q:\n%s", want, got)
154 }
155 }
156 for _, unwanted := range []string{
157 "system prompt should not export",
158 "Referenced context:",
159 "<file path=",
160 "hiddenReference",
161 "private pinned body",
162 "internal steer should not export",
163 "private thinking should not export",
164 "private-tool-input.txt",
165 "tool output should not export",
166 } {
167 if strings.Contains(got, unwanted) {
168 t.Fatalf("export leaked %q:\n%s", unwanted, got)
169 }
170 }
171 }
172
173 func TestSlashCopyIgnoresTrailingPinnedRevision(t *testing.T) {
174 msgs := []provider.Message{
175 {Role: provider.RoleUser, Content: "current prompt"},
176 {Role: provider.RoleAssistant, Content: "current answer"},
177 {Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "<pinned_context_revision>private pinned body</pinned_context_revision>"},
178 }
179 if got := copyAssistantParts(msgs); !reflect.DeepEqual(got, []string{"current answer"}) {
180 t.Fatalf("copyAssistantParts() = %#v, want current answer", got)
181 }
182 }
183
184 func TestSlashExportDoesNotWriteEmptyMarkdown(t *testing.T) {
185 tests := []struct {
186 name string
187 msgs []provider.Message
188 }{
189 {
190 name: "system only",
191 },
192 {
193 name: "filtered only",
194 msgs: []provider.Message{
195 {Role: provider.RoleUser, Content: agent.MidTurnSteerPrefix + "\ninternal steer should not export"},
196 {Role: provider.RoleUser, Content: control.PlanModeMarker},
197 {Role: provider.RoleAssistant, Content: " "},
198 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "tool output should not export"},
199 },
200 },
201 }
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 dir := t.TempDir()
205 m := newTestChatTUIWithMessages(t, dir, tt.msgs...)
206
207 m.runExportCommand("/export")
208
209 if exported := sessionExportFiles(t, dir); len(exported) != 0 {
210 t.Fatalf("exported files = %v, want none", exported)
211 }
212 if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "no messages to export") {
213 t.Fatalf("missing empty-export notice in transcript:\n%s", out)
214 }
215 })
216 }
217 }
218
218 lines GO