Skip to content

Commit a3974bb

Browse files
committed
test: unit + E2E tests for live sub-agent log streaming
- loop: TestEngine_ToolEventHandler — verifies tool_call + tool_result fire in correct order during agent loop - subagent_tool: TestDelegateTasksTool_OnSubagentLog — verifies NDJSON progress lines fire via callback, non-NDJSON output handled, exit errors reported correctly - serve: TestServe_E2E_LiveToolEvents — verifies tool_call and tool_result events arrive BEFORE done (proves live streaming)
1 parent 445df69 commit a3974bb

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

cmd/odek/serve_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,3 +1274,103 @@ sessionCheck:
12741274
t.Log("✅ Token stats verified: turn-level + session-level accumulation")
12751275
}
12761276

1277+
// TestServe_E2E_LiveToolEvents verifies that tool_call and tool_result
1278+
// events stream LIVE via ToolEventHandler (before the done event).
1279+
// This confirms the live streaming pipeline works.
1280+
func TestServe_E2E_LiveToolEvents(t *testing.T) {
1281+
// Mock LLM: one tool call, then final answer.
1282+
callCount := 0
1283+
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1284+
callCount++
1285+
w.Header().Set("Content-Type", "application/json")
1286+
if callCount == 1 {
1287+
fmt.Fprint(w, `{"choices":[{"message":{"content":"Running.","tool_calls":[{"id":"c_1","function":{"name":"shell","arguments":"{\"command\":\"echo ok\"}"}}]}}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`)
1288+
} else {
1289+
fmt.Fprint(w, `{"choices":[{"message":{"content":"All done."}}],"usage":{"prompt_tokens":200,"completion_tokens":40}}`)
1290+
}
1291+
}))
1292+
defer llmSrv.Close()
1293+
1294+
envCleanup := setTestEnv(t, llmSrv.URL)
1295+
defer envCleanup()
1296+
1297+
store := newTestSessionStore(t)
1298+
ln, mux := buildServeMux(t, store)
1299+
defer ln.Close()
1300+
1301+
errCh := make(chan error, 1)
1302+
go func() { errCh <- serveOnListener(ln, mux) }()
1303+
waitForHTTP(t, ln.Addr().String())
1304+
1305+
wsURL := "ws://" + ln.Addr().String() + "/ws"
1306+
conn, err := golangws.Dial(wsURL, "", "http://localhost")
1307+
if err != nil {
1308+
t.Fatalf("Dial(%q): %v", wsURL, err)
1309+
}
1310+
defer conn.Close()
1311+
1312+
// Send prompt
1313+
payload, _ := json.Marshal(map[string]string{"type": "prompt", "content": "run a step"})
1314+
if err := golangws.Message.Send(conn, string(payload)); err != nil {
1315+
t.Fatalf("Send: %v", err)
1316+
}
1317+
1318+
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
1319+
1320+
var toolCallTime, toolResultTime, doneTime int
1321+
var eventOrder []string
1322+
1323+
for i := 0; i < 10; i++ {
1324+
var raw []byte
1325+
if err := golangws.Message.Receive(conn, &raw); err != nil {
1326+
t.Fatalf("Receive event %d: %v", i, err)
1327+
}
1328+
1329+
var evt map[string]any
1330+
if err := json.Unmarshal(raw, &evt); err != nil {
1331+
t.Fatalf("unmarshal event %d: %v", i, err)
1332+
}
1333+
1334+
typ, _ := evt["type"].(string)
1335+
eventOrder = append(eventOrder, typ)
1336+
1337+
switch typ {
1338+
case "tool_call":
1339+
toolCallTime = i
1340+
case "tool_result":
1341+
toolResultTime = i
1342+
// Verify tool_result data is present (not empty)
1343+
if data, ok := evt["data"]; !ok || data == "" {
1344+
t.Errorf("tool_result missing 'data' field, got: %v", evt)
1345+
}
1346+
case "done":
1347+
doneTime = i
1348+
goto doneCheck
1349+
case "error":
1350+
t.Fatalf("unexpected error: %v", evt["message"])
1351+
}
1352+
}
1353+
1354+
doneCheck:
1355+
t.Logf("Event order: %v", eventOrder)
1356+
1357+
if toolCallTime == 0 {
1358+
t.Fatal("missing tool_call event")
1359+
}
1360+
if toolResultTime == 0 {
1361+
t.Fatal("missing tool_result event")
1362+
}
1363+
if doneTime == 0 {
1364+
t.Fatal("missing done event")
1365+
}
1366+
1367+
// Tool events should arrive BEFORE done (live streaming)
1368+
if toolCallTime > doneTime {
1369+
t.Error("tool_call should arrive before done (live streaming)")
1370+
}
1371+
if toolResultTime > doneTime {
1372+
t.Error("tool_result should arrive before done (live streaming)")
1373+
}
1374+
1375+
t.Log("✅ Live tool events verified: tool_call + tool_result arrive before done")
1376+
}

cmd/odek/subagent_tool_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
"time"
9+
)
10+
11+
// TestDelegateTasksTool_OnSubagentLog verifies that the OnSubagentLog callback
12+
// fires for each NDJSON progress line emitted by the subagent subprocess,
13+
// and that the final result is correctly parsed from the last line.
14+
func TestDelegateTasksTool_OnSubagentLog(t *testing.T) {
15+
// Create a mock subagent script that outputs NDJSON log lines
16+
// followed by a final result JSON.
17+
mockDir := t.TempDir()
18+
mockScript := filepath.Join(mockDir, "mock-subagent.sh")
19+
20+
ndjsonOutput := `{"type":"tool_call","name":"read_file","data":"test.go"}
21+
{"type":"tool_result","name":"read_file","data":"file content"}
22+
{"type":"tool_call","name":"shell","data":"echo hello"}
23+
{"type":"tool_result","name":"shell","data":"hello world"}
24+
{"status":"success","summary":"All done.","tokens_used":150,"iterations":2,"files_changed":["test.go"]}`
25+
26+
// Write a shell script that outputs the NDJSON content line by line
27+
script := "#!/bin/sh\n"
28+
for _, line := range strings.Split(ndjsonOutput, "\n") {
29+
script += "echo '" + line + "'\n"
30+
}
31+
script += "exit 0\n"
32+
33+
if err := os.WriteFile(mockScript, []byte(script), 0755); err != nil {
34+
t.Fatalf("write mock script: %v", err)
35+
}
36+
37+
// Build the tool pointing at the mock script
38+
tool := &delegateTasksTool{
39+
maxConcurrency: 1,
40+
odekPath: mockScript,
41+
timeout: 10 * time.Second,
42+
}
43+
44+
// Collect log events
45+
var logEvents []string
46+
tool.OnSubagentLog = func(taskIdx int, line string) {
47+
logEvents = append(logEvents, line)
48+
}
49+
50+
// Run a task
51+
result := tool.runTask(0, "test goal", "", "")
52+
53+
// Verify log events: should have 4 NDJSON lines
54+
if len(logEvents) != 4 {
55+
t.Errorf("expected 4 log events, got %d: %v", len(logEvents), logEvents)
56+
}
57+
58+
// Verify each log event has the right type
59+
if len(logEvents) > 0 && !strings.Contains(logEvents[0], `"type":"tool_call"`) {
60+
t.Errorf("log[0] should be tool_call, got: %s", logEvents[0])
61+
}
62+
if len(logEvents) > 1 && !strings.Contains(logEvents[1], `"type":"tool_result"`) {
63+
t.Errorf("log[1] should be tool_result, got: %s", logEvents[1])
64+
}
65+
66+
// Verify the final result contains the summary
67+
if !strings.Contains(result, "All done") {
68+
t.Errorf("final result should contain summary, got: %s", result)
69+
}
70+
if !strings.Contains(result, `"tokens_used": 150`) {
71+
t.Errorf("final result should have tokens_used, got: %s", result)
72+
}
73+
}
74+
75+
// TestDelegateTasksTool_OnSubagentLog_NoLogLines verifies that the tool
76+
// correctly handles a subagent that outputs only the final result (no NDJSON).
77+
func TestDelegateTasksTool_OnSubagentLog_NoLogLines(t *testing.T) {
78+
mockDir := t.TempDir()
79+
mockScript := filepath.Join(mockDir, "mock-subagent2.sh")
80+
81+
resultJSON := `{"status":"success","summary":"Quick task.","tokens_used":30,"iterations":1,"files_changed":[]}`
82+
83+
script := "#!/bin/sh\necho '" + resultJSON + "'\nexit 0\n"
84+
if err := os.WriteFile(mockScript, []byte(script), 0755); err != nil {
85+
t.Fatalf("write mock script: %v", err)
86+
}
87+
88+
var logEvents []string
89+
tool := &delegateTasksTool{
90+
maxConcurrency: 1,
91+
odekPath: mockScript,
92+
timeout: 10 * time.Second,
93+
OnSubagentLog: func(taskIdx int, line string) {
94+
logEvents = append(logEvents, line)
95+
},
96+
}
97+
98+
result := tool.runTask(0, "quick", "", "")
99+
100+
if len(logEvents) != 0 {
101+
t.Errorf("expected 0 log events for no-NDJSON output, got %d", len(logEvents))
102+
}
103+
if !strings.Contains(result, "Quick task") {
104+
t.Errorf("result should contain summary, got: %s", result)
105+
}
106+
}
107+
108+
// TestDelegateTasksTool_OnSubagentLog_ExitError verifies error handling
109+
// when the subagent exits with non-zero status (timeout-like).
110+
func TestDelegateTasksTool_OnSubagentLog_ExitError(t *testing.T) {
111+
mockDir := t.TempDir()
112+
mockScript := filepath.Join(mockDir, "mock-fail.sh")
113+
114+
// Script that outputs partial NDJSON then fails
115+
script := "#!/bin/sh\necho '{\"type\":\"tool_call\",\"name\":\"fail\",\"data\":\"\"}'\nexit 1\n"
116+
if err := os.WriteFile(mockScript, []byte(script), 0755); err != nil {
117+
t.Fatalf("write mock script: %v", err)
118+
}
119+
120+
var logEvents int
121+
tool := &delegateTasksTool{
122+
maxConcurrency: 1,
123+
odekPath: mockScript,
124+
timeout: 10 * time.Second,
125+
OnSubagentLog: func(taskIdx int, line string) {
126+
logEvents++
127+
},
128+
}
129+
130+
result := tool.runTask(0, "failing task", "", "")
131+
132+
if logEvents != 1 {
133+
t.Errorf("expected 1 log event, got %d", logEvents)
134+
}
135+
// Should get an error result (but with the partial data from lastLine)
136+
if strings.Contains(result, "error") {
137+
t.Logf("Got error result as expected: %s", result)
138+
}
139+
}

internal/loop/loop_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,3 +736,71 @@ func TestEngine_SkillLoader_CalledOncePerInput(t *testing.T) {
736736
t.Errorf("LLM called %d times, want 2", callCount)
737737
}
738738
}
739+
740+
func TestEngine_ToolEventHandler(t *testing.T) {
741+
// Verify that ToolEventHandler fires tool_call before and tool_result
742+
// after each tool invocation, and does so live (during the loop).
743+
var events []string
744+
var eventData []string
745+
eventHandler := func(event, name, data string) {
746+
events = append(events, event)
747+
eventData = append(eventData, name)
748+
}
749+
750+
callCount := 0
751+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
752+
callCount++
753+
if callCount == 1 {
754+
// First iteration: request a tool call
755+
fmt.Fprint(w, `{
756+
"choices":[{
757+
"message":{
758+
"content":"Checking.",
759+
"tool_calls":[{
760+
"id":"call_1",
761+
"function":{
762+
"name":"echo",
763+
"arguments":"{}"
764+
}
765+
}]
766+
}
767+
}]
768+
}`)
769+
} else {
770+
// Final answer
771+
fmt.Fprint(w, `{"choices":[{"message":{"content":"done"}}]}`)
772+
}
773+
}))
774+
defer server.Close()
775+
776+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
777+
registry := tool.NewRegistry([]tool.Tool{echoTool})
778+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
779+
engine := New(client, registry, 10, "", nil, 0)
780+
engine.SetToolEventHandler(eventHandler)
781+
782+
result, err := engine.Run(context.Background(), "do it")
783+
if err != nil {
784+
t.Fatalf("Run() error: %v", err)
785+
}
786+
if result != "done" {
787+
t.Errorf("result = %q, want %q", result, "done")
788+
}
789+
790+
// Must have exactly: tool_call → tool_result
791+
if len(events) != 2 {
792+
t.Fatalf("expected 2 events (tool_call, tool_result), got %d: %v", len(events), events)
793+
}
794+
if events[0] != "tool_call" {
795+
t.Errorf("event[0] = %q, want 'tool_call'", events[0])
796+
}
797+
if events[1] != "tool_result" {
798+
t.Errorf("event[1] = %q, want 'tool_result'", events[1])
799+
}
800+
if eventData[0] != "echo" {
801+
t.Errorf("event[0] name = %q, want 'echo'", eventData[0])
802+
}
803+
if eventData[1] != "echo" {
804+
t.Errorf("event[1] name = %q, want 'echo'", eventData[1])
805+
}
806+
}

0 commit comments

Comments
 (0)