|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "net/http" |
| 8 | + "net/http/httptest" |
| 9 | + "os" |
| 10 | + "os/exec" |
| 11 | + "path/filepath" |
| 12 | + "strings" |
| 13 | + "testing" |
| 14 | + "time" |
| 15 | +) |
| 16 | + |
| 17 | +// ───────────────────────────────────────────────────────────────────── |
| 18 | +// E2E Tests: odek repl (real subprocess) |
| 19 | +// ───────────────────────────────────────────────────────────────────── |
| 20 | +// |
| 21 | +// These tests spawn the real odek binary in REPL mode and pipe input. |
| 22 | +// Since the REPL detects a non-TTY stdin, it falls back to the simple |
| 23 | +// bufio.Scanner mode (no raw terminal). This tests the full pipeline: |
| 24 | +// |
| 25 | +// repl start → prompt → user input → agent loop → output → next prompt |
| 26 | +// |
| 27 | +// Gated by ODEK_E2E=true. |
| 28 | +// |
| 29 | +// Uses the same e2eBinary built by TestMain in subagent_e2e_test.go. |
| 30 | +// ───────────────────────────────────────────────────────────────────── |
| 31 | + |
| 32 | +// echoLLMHandler returns an HTTP handler that simulates a simple LLM. |
| 33 | +// The first call returns a tool call, subsequent calls echo the user's |
| 34 | +// message back as text. The callCount pointer tracks how many requests |
| 35 | +// have been made for multi-step assertions. |
| 36 | +func echoLLMHandler(callCount *int) http.HandlerFunc { |
| 37 | + return func(w http.ResponseWriter, r *http.Request) { |
| 38 | + *callCount++ |
| 39 | + w.Header().Set("Content-Type", "application/json") |
| 40 | + if *callCount <= 1 { |
| 41 | + // First call: tool call (shell echo) |
| 42 | + fmt.Fprint(w, `{"choices":[{"message":{"content":"Checking.","tool_calls":[{"id":"c_1","function":{"name":"shell","arguments":"{\"command\":\"echo Hello from REPL\"}"}}]}}],"usage":{"prompt_tokens":50,"completion_tokens":10}}`) |
| 43 | + } else { |
| 44 | + // Subsequent: text response |
| 45 | + fmt.Fprint(w, `{"choices":[{"message":{"content":"Hello from the agent."}}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`) |
| 46 | + } |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +// TestE2E_REPL_BasicPrompt verifies the REPL starts, shows a prompt, |
| 51 | +// accepts a simple command, runs the agent loop, and exits cleanly. |
| 52 | +func TestE2E_REPL_BasicPrompt(t *testing.T) { |
| 53 | + if os.Getenv("ODEK_E2E") != "true" { |
| 54 | + t.Skip("ODEK_E2E not set — skipping REPL E2E test") |
| 55 | + } |
| 56 | + |
| 57 | + // Start a mock LLM server |
| 58 | + callCount := 0 |
| 59 | + llmSrv := httptest.NewServer(echoLLMHandler(&callCount)) |
| 60 | + defer llmSrv.Close() |
| 61 | + |
| 62 | + // Set up env: mock LLM + isolate home dir |
| 63 | + origDS := os.Getenv("DEEPSEEK_API_KEY") |
| 64 | + origOAI := os.Getenv("OPENAI_API_KEY") |
| 65 | + origHome := os.Getenv("HOME") |
| 66 | + odekBaseURL := os.Getenv("ODEK_BASE_URL") |
| 67 | + |
| 68 | + os.Setenv("DEEPSEEK_API_KEY", "sk-mock-repl") |
| 69 | + os.Unsetenv("OPENAI_API_KEY") |
| 70 | + os.Setenv("ODEK_BASE_URL", llmSrv.URL) |
| 71 | + homeDir := t.TempDir() |
| 72 | + os.Setenv("HOME", homeDir) |
| 73 | + os.Unsetenv("ODEK_SYSTEM") |
| 74 | + |
| 75 | + defer func() { |
| 76 | + os.Setenv("DEEPSEEK_API_KEY", origDS) |
| 77 | + os.Setenv("OPENAI_API_KEY", origOAI) |
| 78 | + os.Setenv("HOME", origHome) |
| 79 | + os.Setenv("ODEK_BASE_URL", odekBaseURL) |
| 80 | + }() |
| 81 | + |
| 82 | + // Ensure binary exists |
| 83 | + if e2eBinary == "" { |
| 84 | + t.Skip("e2eBinary not set — run TestMain first") |
| 85 | + } |
| 86 | + |
| 87 | + // Create a temp session dir so REPL can persist |
| 88 | + sessionDir := filepath.Join(homeDir, ".odek", "sessions") |
| 89 | + if err := os.MkdirAll(sessionDir, 0700); err != nil { |
| 90 | + t.Fatalf("mkdir sessions: %v", err) |
| 91 | + } |
| 92 | + |
| 93 | + // Spawn odek repl |
| 94 | + cmd := exec.Command(e2eBinary, "repl", "--model", "deepseek-chat") |
| 95 | + stdin, err := cmd.StdinPipe() |
| 96 | + if err != nil { |
| 97 | + t.Fatalf("StdinPipe: %v", err) |
| 98 | + } |
| 99 | + stderr, err := cmd.StderrPipe() |
| 100 | + if err != nil { |
| 101 | + t.Fatalf("StderrPipe: %v", err) |
| 102 | + } |
| 103 | + |
| 104 | + if err := cmd.Start(); err != nil { |
| 105 | + t.Fatalf("cmd.Start: %v", err) |
| 106 | + } |
| 107 | + |
| 108 | + // Read stderr in background |
| 109 | + stderrDone := make(chan string, 1) |
| 110 | + go func() { |
| 111 | + data, _ := io.ReadAll(stderr) |
| 112 | + stderrDone <- string(data) |
| 113 | + }() |
| 114 | + |
| 115 | + // Give REPL time to initialize |
| 116 | + time.Sleep(500 * time.Millisecond) |
| 117 | + |
| 118 | + // Send a prompt command |
| 119 | + fmt.Fprintln(stdin, "say hello") |
| 120 | + time.Sleep(2 * time.Second) |
| 121 | + |
| 122 | + // Send exit |
| 123 | + fmt.Fprintln(stdin, "/exit") |
| 124 | + time.Sleep(500 * time.Millisecond) |
| 125 | + |
| 126 | + // Close stdin |
| 127 | + stdin.Close() |
| 128 | + |
| 129 | + // Wait for process to finish |
| 130 | + done := make(chan error, 1) |
| 131 | + go func() { |
| 132 | + done <- cmd.Wait() |
| 133 | + }() |
| 134 | + |
| 135 | + select { |
| 136 | + case err := <-done: |
| 137 | + if err != nil { |
| 138 | + t.Logf("odek repl exited with error: %v", err) |
| 139 | + } |
| 140 | + case <-time.After(10 * time.Second): |
| 141 | + t.Fatal("timed out waiting for repl to exit") |
| 142 | + } |
| 143 | + |
| 144 | + // Collect output |
| 145 | + stderrOutput := <-stderrDone |
| 146 | + |
| 147 | + // Check for key REPL elements |
| 148 | + checks := []struct { |
| 149 | + name string |
| 150 | + target string |
| 151 | + }{ |
| 152 | + {"turn header", "─── Turn 1 ───"}, |
| 153 | + {"prompt", "odek 1>"}, |
| 154 | + {"session created", "odek ⚡"}, |
| 155 | + {"agent response", "Hello"}, |
| 156 | + {"exit message", "Session"}, |
| 157 | + } |
| 158 | + |
| 159 | + for _, c := range checks { |
| 160 | + if !strings.Contains(stderrOutput, c.target) { |
| 161 | + t.Errorf("missing %s: expected %q in output", c.name, c.target) |
| 162 | + } |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +// TestE2E_REPL_MultiTurn verifies the REPL handles multiple turns, |
| 167 | +// showing incrementing turn numbers and persisting session state. |
| 168 | +func TestE2E_REPL_MultiTurn(t *testing.T) { |
| 169 | + if os.Getenv("ODEK_E2E") != "true" { |
| 170 | + t.Skip("ODEK_E2E not set — skipping REPL E2E test") |
| 171 | + } |
| 172 | + |
| 173 | + callCount := 0 |
| 174 | + llmSrv := httptest.NewServer(echoLLMHandler(&callCount)) |
| 175 | + defer llmSrv.Close() |
| 176 | + |
| 177 | + origDS := os.Getenv("DEEPSEEK_API_KEY") |
| 178 | + origOAI := os.Getenv("OPENAI_API_KEY") |
| 179 | + origHome := os.Getenv("HOME") |
| 180 | + odekBaseURL := os.Getenv("ODEK_BASE_URL") |
| 181 | + |
| 182 | + os.Setenv("DEEPSEEK_API_KEY", "sk-mock-repl") |
| 183 | + os.Unsetenv("OPENAI_API_KEY") |
| 184 | + os.Setenv("ODEK_BASE_URL", llmSrv.URL) |
| 185 | + homeDir := t.TempDir() |
| 186 | + os.Setenv("HOME", homeDir) |
| 187 | + os.Unsetenv("ODEK_SYSTEM") |
| 188 | + |
| 189 | + defer func() { |
| 190 | + os.Setenv("DEEPSEEK_API_KEY", origDS) |
| 191 | + os.Setenv("OPENAI_API_KEY", origOAI) |
| 192 | + os.Setenv("HOME", origHome) |
| 193 | + os.Setenv("ODEK_BASE_URL", odekBaseURL) |
| 194 | + }() |
| 195 | + |
| 196 | + if e2eBinary == "" { |
| 197 | + t.Skip("e2eBinary not set") |
| 198 | + } |
| 199 | + |
| 200 | + sessionDir := filepath.Join(homeDir, ".odek", "sessions") |
| 201 | + if err := os.MkdirAll(sessionDir, 0700); err != nil { |
| 202 | + t.Fatalf("mkdir sessions: %v", err) |
| 203 | + } |
| 204 | + |
| 205 | + cmd := exec.Command(e2eBinary, "repl", "--model", "deepseek-chat") |
| 206 | + stdin, err := cmd.StdinPipe() |
| 207 | + if err != nil { |
| 208 | + t.Fatalf("StdinPipe: %v", err) |
| 209 | + } |
| 210 | + stderr, err := cmd.StderrPipe() |
| 211 | + if err != nil { |
| 212 | + t.Fatalf("StderrPipe: %v", err) |
| 213 | + } |
| 214 | + |
| 215 | + if err := cmd.Start(); err != nil { |
| 216 | + t.Fatalf("cmd.Start: %v", err) |
| 217 | + } |
| 218 | + |
| 219 | + stderrDone := make(chan string, 1) |
| 220 | + go func() { |
| 221 | + data, _ := io.ReadAll(stderr) |
| 222 | + stderrDone <- string(data) |
| 223 | + }() |
| 224 | + |
| 225 | + time.Sleep(500 * time.Millisecond) |
| 226 | + |
| 227 | + // Turn 1 |
| 228 | + fmt.Fprintln(stdin, "first command") |
| 229 | + time.Sleep(2 * time.Second) |
| 230 | + |
| 231 | + // Turn 2 |
| 232 | + fmt.Fprintln(stdin, "second command") |
| 233 | + time.Sleep(2 * time.Second) |
| 234 | + |
| 235 | + // Turn 3 — use a slash command |
| 236 | + fmt.Fprintln(stdin, "/info") |
| 237 | + time.Sleep(500 * time.Millisecond) |
| 238 | + |
| 239 | + // Exit |
| 240 | + fmt.Fprintln(stdin, "/exit") |
| 241 | + time.Sleep(500 * time.Millisecond) |
| 242 | + |
| 243 | + stdin.Close() |
| 244 | + |
| 245 | + done := make(chan error, 1) |
| 246 | + go func() { |
| 247 | + done <- cmd.Wait() |
| 248 | + }() |
| 249 | + |
| 250 | + select { |
| 251 | + case err := <-done: |
| 252 | + if err != nil { |
| 253 | + t.Logf("exited: %v", err) |
| 254 | + } |
| 255 | + case <-time.After(10 * time.Second): |
| 256 | + t.Fatal("timed out") |
| 257 | + } |
| 258 | + |
| 259 | + output := <-stderrDone |
| 260 | + |
| 261 | + // Verify multi-turn progression |
| 262 | + if !strings.Contains(output, "─── Turn 2 ───") { |
| 263 | + t.Error("missing Turn 2 header") |
| 264 | + } |
| 265 | + if !strings.Contains(output, "─── Turn 3 ───") { |
| 266 | + t.Error("missing Turn 3 header") |
| 267 | + } |
| 268 | + if !strings.Contains(output, "Session:") { |
| 269 | + t.Error("missing /info output") |
| 270 | + } |
| 271 | + if !strings.Contains(output, "Turns:") { |
| 272 | + t.Error("missing turn count from /info") |
| 273 | + } |
| 274 | +} |
| 275 | + |
| 276 | +// TestE2E_REPL_SlashHelp verifies the /help command works. |
| 277 | +func TestE2E_REPL_SlashHelp(t *testing.T) { |
| 278 | + if os.Getenv("ODEK_E2E") != "true" { |
| 279 | + t.Skip("ODEK_E2E not set — skipping REPL E2E test") |
| 280 | + } |
| 281 | + |
| 282 | + llmSrv := httptest.NewServer(echoLLMHandler(new(int))) |
| 283 | + defer llmSrv.Close() |
| 284 | + |
| 285 | + origDS := os.Getenv("DEEPSEEK_API_KEY") |
| 286 | + origHome := os.Getenv("HOME") |
| 287 | + odekBaseURL := os.Getenv("ODEK_BASE_URL") |
| 288 | + |
| 289 | + os.Setenv("DEEPSEEK_API_KEY", "sk-mock-repl") |
| 290 | + os.Setenv("ODEK_BASE_URL", llmSrv.URL) |
| 291 | + homeDir := t.TempDir() |
| 292 | + os.Setenv("HOME", homeDir) |
| 293 | + os.Unsetenv("ODEK_SYSTEM") |
| 294 | + |
| 295 | + defer func() { |
| 296 | + os.Setenv("DEEPSEEK_API_KEY", origDS) |
| 297 | + os.Setenv("HOME", origHome) |
| 298 | + os.Setenv("ODEK_BASE_URL", odekBaseURL) |
| 299 | + }() |
| 300 | + |
| 301 | + if e2eBinary == "" { |
| 302 | + t.Skip("e2eBinary not set") |
| 303 | + } |
| 304 | + |
| 305 | + cmd := exec.Command(e2eBinary, "repl", "--model", "deepseek-chat") |
| 306 | + stdin, err := cmd.StdinPipe() |
| 307 | + if err != nil { |
| 308 | + t.Fatalf("StdinPipe: %v", err) |
| 309 | + } |
| 310 | + stderr, err := cmd.StderrPipe() |
| 311 | + if err != nil { |
| 312 | + t.Fatalf("StderrPipe: %v", err) |
| 313 | + } |
| 314 | + |
| 315 | + if err := cmd.Start(); err != nil { |
| 316 | + t.Fatalf("cmd.Start: %v", err) |
| 317 | + } |
| 318 | + |
| 319 | + stderrDone := make(chan string, 1) |
| 320 | + go func() { |
| 321 | + data, _ := io.ReadAll(stderr) |
| 322 | + stderrDone <- string(data) |
| 323 | + }() |
| 324 | + |
| 325 | + time.Sleep(500 * time.Millisecond) |
| 326 | + |
| 327 | + fmt.Fprintln(stdin, "/help") |
| 328 | + time.Sleep(500 * time.Millisecond) |
| 329 | + fmt.Fprintln(stdin, "/exit") |
| 330 | + time.Sleep(500 * time.Millisecond) |
| 331 | + stdin.Close() |
| 332 | + |
| 333 | + done := make(chan error, 1) |
| 334 | + go func() { |
| 335 | + done <- cmd.Wait() |
| 336 | + }() |
| 337 | + select { |
| 338 | + case <-done: |
| 339 | + case <-time.After(10 * time.Second): |
| 340 | + t.Fatal("timed out") |
| 341 | + } |
| 342 | + |
| 343 | + output := <-stderrDone |
| 344 | + if !strings.Contains(output, "/exit") { |
| 345 | + t.Error("missing /exit in help output") |
| 346 | + } |
| 347 | + if !strings.Contains(output, "/info") { |
| 348 | + t.Error("missing /info in help output") |
| 349 | + } |
| 350 | +} |
| 351 | + |
| 352 | +func init() { |
| 353 | + // Ensure json import is used |
| 354 | + _ = json.Marshal |
| 355 | +} |
0 commit comments