Skip to content

Commit e1fca69

Browse files
committed
e2e: add REPL E2E tests (BasicPrompt, MultiTurn, SlashHelp)
Tests spawn the real odek binary with a mock LLM server and verify: - REPL startup (session creation, prompt display) - Single-turn agent execution (tool call → response) - Multi-turn progression (incrementing turn numbers) - Slash command handling (/help, /info) - Clean exit (/exit)
1 parent e879f16 commit e1fca69

1 file changed

Lines changed: 355 additions & 0 deletions

File tree

cmd/odek/repl_e2e_test.go

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
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

Comments
 (0)