Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
d8a8337ea2 fix(agent): rewrite openclaw tests to match new backend API
The openclaw backend was rewritten in #715 to parse a single JSON blob
instead of streaming NDJSON events. The tests still referenced the old
types (openclawEvent) and methods (handleOCTextEvent, etc.), causing a
build failure in CI.

Rewrite all tests to exercise the new processOutput method and
openclawInt64 helper.
2026-04-11 22:22:47 +08:00
Jiang Bohan
d01d553898 fix(cli): poll health endpoint instead of fixed sleep in daemon start
The daemon start command waited a fixed 2 seconds then checked the
health endpoint once. If the daemon took longer to initialize (auth,
workspace loading), the check failed and printed a misleading error
even though the daemon started successfully.

Replace the single check with a polling loop (500ms interval, 15s
timeout) so the CLI waits for the daemon to actually be ready.
2026-04-11 22:18:29 +08:00
2 changed files with 189 additions and 525 deletions

View File

@@ -174,12 +174,20 @@ func runDaemonBackground(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err)
}
// Wait briefly and verify daemon started via health endpoint.
time.Sleep(2 * time.Second)
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel2()
health = checkDaemonHealthOnPort(ctx2, healthPort)
if health["status"] != "running" {
// Poll health endpoint until the daemon is ready or timeout.
deadline := time.Now().Add(15 * time.Second)
started := false
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
hctx, hcancel := context.WithTimeout(context.Background(), 2*time.Second)
health = checkDaemonHealthOnPort(hctx, healthPort)
hcancel()
if health["status"] == "running" {
started = true
break
}
}
if !started {
fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath)
return nil
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"log/slog"
"strings"
"testing"
@@ -17,426 +18,47 @@ func TestNewReturnsOpenclawBackend(t *testing.T) {
}
}
// ── Text event tests ──
// ── processOutput tests ──
func TestOpenclawHandleTextEvent(t *testing.T) {
func TestOpenclawProcessOutputHappyPath(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
event := openclawEvent{
Type: "text",
SessionID: "ses_abc",
Data: map[string]any{"text": "Hello from openclaw"},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "Hello from openclaw" {
t.Errorf("output: got %q, want %q", output.String(), "Hello from openclaw")
}
msg := <-ch
if msg.Type != MessageText {
t.Errorf("type: got %v, want MessageText", msg.Type)
}
if msg.Content != "Hello from openclaw" {
t.Errorf("content: got %q, want %q", msg.Content, "Hello from openclaw")
}
}
func TestOpenclawHandleTextEventEmpty(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{
Type: "text",
Data: map[string]any{"text": ""},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
func TestOpenclawHandleTextEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{Type: "text"}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
// ── Thinking event tests ──
func TestOpenclawHandleThinkingEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{"text": "Let me think about this..."},
}
b.handleOCThinkingEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
}
if msg.Content != "Let me think about this..." {
t.Errorf("content: got %q", msg.Content)
}
}
// ── Tool call event tests ──
func TestOpenclawHandleToolCallCompleted(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "bash",
"callId": "call_123",
"input": map[string]any{"command": "pwd"},
"status": "completed",
"output": "/tmp/project\n",
result := openclawResult{
Payloads: []openclawPayload{{Text: "Hello from openclaw"}},
Meta: openclawMeta{
DurationMs: 1234,
AgentMeta: map[string]any{
"sessionId": "ses_abc",
"usage": map[string]any{
"input": float64(100),
"output": float64(50),
"cacheRead": float64(10),
"cacheWrite": float64(5),
},
},
},
}
data, _ := json.Marshal(result)
b.handleOCToolCallEvent(event, ch)
res := b.processOutput(strings.NewReader(string(data)), ch)
// Should emit both tool-use and tool-result.
if len(ch) != 2 {
t.Fatalf("expected 2 messages, got %d", len(ch))
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
// First: tool-use
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
if res.output != "Hello from openclaw" {
t.Errorf("output: got %q, want %q", res.output, "Hello from openclaw")
}
if msg.Tool != "bash" {
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
if res.sessionID != "ses_abc" {
t.Errorf("sessionID: got %q, want %q", res.sessionID, "ses_abc")
}
if msg.CallID != "call_123" {
t.Errorf("callID: got %q, want %q", msg.CallID, "call_123")
if res.usage.InputTokens != 100 {
t.Errorf("input tokens: got %d, want 100", res.usage.InputTokens)
}
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
t.Errorf("input.command: got %v", msg.Input["command"])
}
// Second: tool-result
msg = <-ch
if msg.Type != MessageToolResult {
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
}
if msg.Output != "/tmp/project\n" {
t.Errorf("output: got %q", msg.Output)
}
}
func TestOpenclawHandleToolCallPending(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "read",
"callId": "call_456",
"input": map[string]any{"filePath": "/tmp/test.go"},
"status": "pending",
},
}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
}
func TestOpenclawHandleToolCallNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{Type: "tool_call"}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 0 {
t.Errorf("expected no messages for nil data, got %d", len(ch))
}
}
// ── Error event tests ──
func TestOpenclawHandleErrorEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
SessionID: "ses_abc",
Data: map[string]any{"message": "Model not found: bad/model"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if status != "failed" {
t.Errorf("status: got %q, want %q", status, "failed")
}
if errMsg != "Model not found: bad/model" {
t.Errorf("error: got %q", errMsg)
}
msg := <-ch
if msg.Type != MessageError {
t.Errorf("type: got %v, want MessageError", msg.Type)
}
}
func TestOpenclawHandleErrorEventCodeOnly(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
Data: map[string]any{"code": "RateLimitError"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "RateLimitError" {
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
}
}
func TestOpenclawHandleErrorEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{Type: "error"}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "unknown openclaw error" {
t.Errorf("error: got %q, want %q", errMsg, "unknown openclaw error")
}
}
// ── Integration-level tests: processEvents ──
func TestOpenclawProcessEventsHappyPath(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
// Simulate a successful run: step_start → text → tool_call → text → step_end
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_happy"}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":"Analyzing..."}}`,
`{"type":"tool_call","sessionId":"ses_happy","data":{"name":"bash","callId":"call_1","input":{"command":"ls"},"status":"completed","output":"file.go\n"}}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":" Done."}}`,
`{"type":"step_end","sessionId":"ses_happy"}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.sessionID != "ses_happy" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
}
if result.output != "Analyzing... Done." {
t.Errorf("output: got %q, want %q", result.output, "Analyzing... Done.")
}
if result.errMsg != "" {
t.Errorf("errMsg: got %q, want empty", result.errMsg)
}
// Drain and verify messages.
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
// Expected: status(running), text, tool-use, tool-result, text = 5 messages
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
}
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing..." {
t.Errorf("msg[1]: got %+v", msgs[1])
}
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
}
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file.go\n" {
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
}
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
t.Errorf("msg[4]: got %+v", msgs[4])
}
}
func TestOpenclawProcessEventsErrorCausesFailedStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_err"}`,
`{"type":"error","sessionId":"ses_err","data":{"message":"Model not found: bad/model"}}`,
`{"type":"step_end","sessionId":"ses_err"}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "Model not found: bad/model" {
t.Errorf("errMsg: got %q", result.errMsg)
}
if result.sessionID != "ses_err" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
}
close(ch)
var errorMsgs int
for m := range ch {
if m.Type == MessageError {
errorMsgs++
}
}
if errorMsgs != 1 {
t.Errorf("expected 1 error message, got %d", errorMsgs)
}
}
func TestOpenclawProcessEventsSessionIDExtracted(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_first"}`,
`{"type":"text","sessionId":"ses_updated","data":{"text":"hi"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.sessionID != "ses_updated" {
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
}
close(ch)
}
func TestOpenclawProcessEventsScannerError(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := b.processEvents(&ioErrReader{
data: `{"type":"text","sessionId":"ses_scan","data":{"text":"before error"}}` + "\n",
}, ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if !strings.Contains(result.errMsg, "stdout read error") {
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
}
if result.output != "before error" {
t.Errorf("output: got %q, want %q", result.output, "before error")
}
close(ch)
}
func TestOpenclawProcessEventsEmptyLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
"",
" ",
"not json at all",
`{"type":"text","sessionId":"ses_ok","data":{"text":"valid"}}`,
"",
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "valid" {
t.Errorf("output: got %q, want %q", result.output, "valid")
}
if result.sessionID != "ses_ok" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
if res.usage.OutputTokens != 50 {
t.Errorf("output tokens: got %d, want 50", res.usage.OutputTokens)
}
close(ch)
@@ -445,130 +67,164 @@ func TestOpenclawProcessEventsEmptyLines(t *testing.T) {
msgs = append(msgs, m)
}
if len(msgs) != 1 || msgs[0].Type != MessageText {
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
t.Errorf("expected 1 text message, got %d", len(msgs))
}
if msgs[0].Content != "Hello from openclaw" {
t.Errorf("message content: got %q", msgs[0].Content)
}
}
func TestOpenclawProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
func TestOpenclawProcessOutputMultiplePayloads(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"error","sessionId":"ses_x","data":{"message":"RateLimitError"}}`,
`{"type":"text","sessionId":"ses_x","data":{"text":"recovered?"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
}
if result.errMsg != "RateLimitError" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
}
close(ch)
}
func TestOpenclawProcessEventsResultEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"text","sessionId":"ses_r","data":{"text":"Done"}}`,
`{"type":"result","sessionId":"ses_r","data":{"status":"completed"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "Done" {
t.Errorf("output: got %q, want %q", result.output, "Done")
}
close(ch)
}
func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"result","sessionId":"ses_rf","data":{"status":"error","error":"out of tokens"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "out of tokens" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "out of tokens")
}
close(ch)
}
// ── openclawExtractText tests ──
func TestExtractEventTextDirect(t *testing.T) {
t.Parallel()
data := map[string]any{"text": "hello"}
if got := openclawExtractText(data); got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestExtractEventTextNested(t *testing.T) {
t.Parallel()
data := map[string]any{
"content": map[string]any{"text": "nested hello"},
}
if got := openclawExtractText(data); got != "nested hello" {
t.Errorf("got %q, want %q", got, "nested hello")
}
}
func TestExtractEventTextNil(t *testing.T) {
t.Parallel()
if got := openclawExtractText(nil); got != "" {
t.Errorf("got %q, want empty", got)
}
}
// ── Thinking event with nested content ──
func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{
"content": map[string]any{"text": "Nested thinking"},
result := openclawResult{
Payloads: []openclawPayload{
{Text: "First"},
{Text: "Second"},
},
}
data, _ := json.Marshal(result)
b.handleOCThinkingEvent(event, ch)
res := b.processOutput(strings.NewReader(string(data)), ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
if res.output != "First\nSecond" {
t.Errorf("output: got %q, want %q", res.output, "First\nSecond")
}
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
close(ch)
}
func TestOpenclawProcessOutputEmptyPayloads(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{Payloads: []openclawPayload{}}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if msg.Content != "Nested thinking" {
t.Errorf("content: got %q, want %q", msg.Content, "Nested thinking")
if res.output != "" {
t.Errorf("output: got %q, want empty", res.output)
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestOpenclawProcessOutputWithLeadingLogLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Done"}},
}
data, _ := json.Marshal(result)
input := "some log line\nanother log\n" + string(data)
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Done" {
t.Errorf("output: got %q, want %q", res.output, "Done")
}
close(ch)
}
func TestOpenclawProcessOutputNoJSON(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(strings.NewReader("not json at all"), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "not json at all" {
t.Errorf("output: got %q", res.output)
}
close(ch)
}
func TestOpenclawProcessOutputEmptyInput(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(strings.NewReader(""), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "openclaw returned no parseable output" {
t.Errorf("errMsg: got %q", res.errMsg)
}
close(ch)
}
func TestOpenclawProcessOutputReadError(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(&ioErrReader{data: ""}, ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if !strings.Contains(res.errMsg, "read stderr") {
t.Errorf("errMsg: got %q, want it to contain 'read stderr'", res.errMsg)
}
close(ch)
}
// ── openclawInt64 tests ──
func TestOpenclawInt64Float(t *testing.T) {
t.Parallel()
data := map[string]any{"count": float64(42)}
if got := openclawInt64(data, "count"); got != 42 {
t.Errorf("got %d, want 42", got)
}
}
func TestOpenclawInt64Missing(t *testing.T) {
t.Parallel()
data := map[string]any{}
if got := openclawInt64(data, "count"); got != 0 {
t.Errorf("got %d, want 0", got)
}
}
func TestOpenclawInt64Nil(t *testing.T) {
t.Parallel()
data := map[string]any{"count": "not a number"}
if got := openclawInt64(data, "count"); got != 0 {
t.Errorf("got %d, want 0", got)
}
}