返回 DeepSeek-Reasonix
shell_test.go
根目录 / internal / control / shell_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/i18n"
16 "reasonix/internal/sandbox"
17 )
18
19 // collectSink returns a Sink that collects events and a channel that receives
20 // the TurnDone event when the turn finishes. The channel lets tests wait for
21 // the runGuarded goroutine to complete.
22 func collectSink() (event.Sink, chan event.Event, *[]event.Event) {
23 var events []event.Event
24 var mu sync.Mutex
25 done := make(chan event.Event, 1)
26 sink := event.FuncSink(func(e event.Event) {
27 mu.Lock()
28 defer mu.Unlock()
29 events = append(events, e)
30 if e.Kind == event.TurnDone {
31 done <- e
32 }
33 })
34 return sink, done, &events
35 }
36
37 func waitForDone(t *testing.T, done chan event.Event) event.Event {
38 t.Helper()
39 return waitForDoneWithin(t, done, 5*time.Second)
40 }
41
42 func waitForDoneWithin(t *testing.T, done chan event.Event, d time.Duration) event.Event {
43 t.Helper()
44 select {
45 case e := <-done:
46 return e
47 case <-time.After(d):
48 t.Fatal("timed out waiting for TurnDone")
49 return event.Event{}
50 }
51 }
52
53 func TestRunShell_EmitsEvents(t *testing.T) {
54 sink, done, events := collectSink()
55 ctrl := &Controller{sink: sink}
56
57 ctrl.RunShell("echo hello")
58 waitForDone(t, done)
59
60 if len(*events) < 3 {
61 t.Fatalf("expected at least 3 events, got %d: %v", len(*events), *events)
62 }
63
64 // First event: ToolDispatch
65 if (*events)[0].Kind != event.ToolDispatch {
66 t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
67 }
68 wantShell := "bash"
69 if runtime.GOOS == "windows" {
70 wantShell = "pwsh"
71 }
72 if (*events)[0].Tool.Name != wantShell {
73 t.Errorf("tool name: want %s, got %s", wantShell, (*events)[0].Tool.Name)
74 }
75
76 // Last event: TurnDone
77 td := (*events)[len(*events)-1]
78 if td.Kind != event.TurnDone {
79 t.Errorf("last event: want TurnDone, got %v", td.Kind)
80 }
81 if td.CheckpointTurn != nil {
82 t.Errorf("shell TurnDone checkpoint = %d, want nil", *td.CheckpointTurn)
83 }
84
85 // Penultimate event: ToolResult
86 last := (*events)[len(*events)-2]
87 if last.Kind != event.ToolResult {
88 t.Errorf("penultimate event: want ToolResult, got %v", last.Kind)
89 }
90 if last.Tool.Err != "" {
91 t.Errorf("unexpected error: %s", last.Tool.Err)
92 }
93 if !strings.Contains(last.Tool.Output, "hello") {
94 t.Errorf("output should contain 'hello', got: %s", last.Tool.Output)
95 }
96 }
97
98 func TestSubmit_BangPrefix(t *testing.T) {
99 sink, done, events := collectSink()
100 ctrl := &Controller{sink: sink}
101
102 ctrl.Submit("!echo test")
103 waitForDone(t, done)
104
105 if len(*events) == 0 {
106 t.Fatal("expected events from !echo, got none")
107 }
108 if (*events)[0].Kind != event.ToolDispatch {
109 t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
110 }
111 }
112
113 func TestSubmit_BangEmpty(t *testing.T) {
114 var notices []string
115 sink := event.FuncSink(func(e event.Event) {
116 if e.Kind == event.Notice {
117 notices = append(notices, e.Text)
118 }
119 })
120
121 ctrl := &Controller{sink: sink}
122 ctrl.Submit("!")
123
124 if len(notices) == 0 {
125 t.Fatal("expected a notice for bare !")
126 }
127 if !strings.Contains(notices[0], "!") {
128 t.Errorf("notice should mention usage, got: %s", notices[0])
129 }
130 }
131
132 func TestSubmit_BangNotFirstChar(t *testing.T) {
133 // "! " not at position 0 should NOT trigger shell. Submit routes to
134 // runRefTurn for normal text, which needs a runner — so we test the
135 // prefix-check condition directly.
136 input := "tell me about !important"
137 trimmed := strings.TrimSpace(input)
138 if strings.HasPrefix(trimmed, "!") {
139 t.Error("trimmed input should not start with !")
140 }
141 }
142
143 func TestRunShell_FailingCommand(t *testing.T) {
144 sink, done, events := collectSink()
145 ctrl := &Controller{sink: sink}
146
147 ctrl.RunShell("false") // exits 1
148 waitForDone(t, done)
149
150 // Find the ToolResult
151 var result *event.Event
152 for i := range *events {
153 if (*events)[i].Kind == event.ToolResult {
154 result = &(*events)[i]
155 break
156 }
157 }
158 if result == nil {
159 t.Fatal("expected a ToolResult event")
160 } else if result.Tool.Err == "" {
161 t.Error("failing command should produce an error string")
162 }
163 }
164
165 func TestRunShell_CancelStopsCommand(t *testing.T) {
166 sink, done, events := collectSink()
167 ctrl := &Controller{sink: sink}
168
169 command := "sleep 30"
170 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
171 command = "Start-Sleep -Seconds 30"
172 }
173 ctrl.RunShell(command)
174 time.Sleep(100 * time.Millisecond)
175 ctrl.Cancel()
176
177 // Cancel kills the shell via the run context, but cmd.Wait honours
178 // shellWaitDelay (and on Windows cmd.Cancel spawns taskkill /F /T), so
179 // TurnDone can arrive almost a full shellWaitDelay after Cancel. Wait
180 // comfortably longer than that grace — a flat 5s budget equalled
181 // shellWaitDelay and lost the race on a loaded windows runner.
182 e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
183 if e.Kind != event.TurnDone {
184 t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
185 }
186 if e.Err != nil {
187 t.Fatalf("cancelled shell TurnDone err = %v, want nil", e.Err)
188 }
189 var result *event.Event
190 for i := range *events {
191 if (*events)[i].Kind == event.ToolResult {
192 result = &(*events)[i]
193 break
194 }
195 }
196 if result == nil {
197 t.Fatal("expected ToolResult for cancelled shell")
198 }
199 if result.Tool.Err != i18n.M.TurnCancelled {
200 t.Fatalf("cancelled shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
201 }
202 }
203
204 func TestRunShell_HeredocCancelReleasesTurn(t *testing.T) {
205 sh := requireRunShellHereDocBash(t)
206 sink, done, events := collectSink()
207 root := t.TempDir()
208 target := filepath.Join(root, "test_redact.go")
209 ctrl := &Controller{sink: sink, shell: sh, workspaceRoot: root}
210
211 command := strings.Join([]string{
212 "cat > " + controlShellQuote(filepath.ToSlash(target)) + " <<'EOF'",
213 "package main",
214 "",
215 "import (",
216 "\t\"encoding/json\"",
217 "\t\"fmt\"",
218 ")",
219 "",
220 "func main() {",
221 "\tdata := []byte(`{\"accounts\":[{\"id\":\"a1\",\"username\":\"alice\",\"token\":\"TOKEN_EXAMPLE\"}]}`)",
222 "\tvar v any",
223 "\tjson.Unmarshal(data, &v)",
224 "\tfmt.Printf(\"before: %v\\n\", v)",
225 "}",
226 "EOF",
227 "sleep 30",
228 }, "\n")
229
230 ctrl.RunShell(command)
231 if !waitForFileContainingWithin(target, "TOKEN_EXAMPLE", 2*time.Second) {
232 ctrl.Cancel()
233 waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
234 t.Fatalf("heredoc target body was not written before cancel: %s", target)
235 }
236 ctrl.Cancel()
237
238 e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
239 if e.Kind != event.TurnDone {
240 t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
241 }
242 if e.Err != nil {
243 t.Fatalf("cancelled heredoc shell TurnDone err = %v, want nil", e.Err)
244 }
245 var result *event.Event
246 for i := range *events {
247 if (*events)[i].Kind == event.ToolResult {
248 result = &(*events)[i]
249 break
250 }
251 }
252 if result == nil {
253 t.Fatal("expected ToolResult for cancelled heredoc shell")
254 }
255 if result.Tool.Err != i18n.M.TurnCancelled {
256 t.Fatalf("cancelled heredoc shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
257 }
258 data, err := os.ReadFile(target)
259 if err != nil {
260 t.Fatalf("read heredoc target: %v", err)
261 }
262 if !strings.Contains(string(data), "TOKEN_EXAMPLE") {
263 t.Fatalf("heredoc target missing expected body:\n%s", data)
264 }
265 }
266
267 func requireRunShellHereDocBash(t *testing.T) sandbox.Shell {
268 t.Helper()
269 sh := sandbox.ResolveShell("bash", "", nil)
270 if sh.Kind != sandbox.ShellBash {
271 t.Skipf("bash heredoc regression requires bash, got %s", sh.Kind.String())
272 }
273 path := sh.Path
274 if path == "" {
275 path = "bash"
276 }
277 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
278 defer cancel()
279 if err := exec.CommandContext(ctx, path, "-c", "true").Run(); err != nil {
280 t.Skipf("bash heredoc regression requires a runnable bash: %v", err)
281 }
282 sh.Path = path
283 return sh
284 }
285
286 func waitForFileContainingWithin(path, want string, d time.Duration) bool {
287 deadline := time.Now().Add(d)
288 for time.Now().Before(deadline) {
289 if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), want) {
290 return true
291 }
292 time.Sleep(20 * time.Millisecond)
293 }
294 return false
295 }
296
297 func controlShellQuote(s string) string {
298 return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
299 }
300
300 lines GO