fix(agent/openclaw): read --json from stdout, not stderr

Multica's openclaw runtime adapter has been reading agent output from
stderr since the early openclaw integration days. Current openclaw
(2026.5.5, c37871e) writes its --json blob exclusively to stdout:

    $ openclaw agent --local --json --agent main --message 'say hi' >stdout 2>stderr
    STDOUT bytes: 27401
    STDERR bytes:     0

Result: every successful turn was followed by a daemon-generated system
comment 'openclaw returned no parseable output', visible to users,
looked like the agent broke when it didn't. Reproduced live on WOR-2,
turn at 2026-05-05 16:35 UTC; daemon log confirmed the full result JSON
arrived on the [openclaw:stdout] debug channel and was discarded while
the empty stderr pipe hit the no-events fallback.

Changes
- server/pkg/agent/openclaw.go: swap pipes, StdoutPipe() for the JSON
  stream, cmd.Stderr = newLogWriter(...) for log overflow. Cleanup
  goroutine now closes stdout on cancel. Comments and the read-error
  errMsg updated to reflect the new pipe.
- server/pkg/agent/openclaw_test.go: TestOpenclawProcessOutputReadError
  asserts on 'read stdout' (was 'read stderr'), string-only fix,
  no behavior change. New TestOpenclawProcessOutputStdoutFixture feeds
  a recorded openclaw 2026.5.5 --json blob through processOutput and
  asserts result + messages parse cleanly.
- server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json: 27401-byte
  fixture captured fresh from the openclaw CLI for the regression test.

Side effects (net positive)
- Log lines openclaw writes to stderr (security warnings, tool errors)
  now show up under [openclaw:stderr] instead of being silently consumed
  by the JSON parser.
- Daemon's success_pattern heuristic (empty-output -> 'blocked')
  becomes meaningful again because result.Output actually populates.

Closes WOR-10.
This commit is contained in:
Joey Frasier
2026-05-05 12:50:46 -04:00
committed by Joey Frasier (Boothe)
parent daf0e935f6
commit c87d7676f6
3 changed files with 1153 additions and 14 deletions

View File

@@ -60,13 +60,16 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
} }
cmd.Env = buildEnv(b.cfg.Env) cmd.Env = buildEnv(b.cfg.Env)
// openclaw writes its --json output to stderr, not stdout. // openclaw writes its --json output to stdout. Stderr carries log
stderr, err := cmd.StderrPipe() // overflow (security warnings, tool errors, etc.) — capture it via a
// log writer so it surfaces in daemon logs without being fed into the
// JSON parser.
stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
cancel() cancel()
return nil, fmt.Errorf("openclaw stderr pipe: %w", err) return nil, fmt.Errorf("openclaw stdout pipe: %w", err)
} }
cmd.Stdout = newLogWriter(b.cfg.Logger, "[openclaw:stdout] ") cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
cancel() cancel()
@@ -78,10 +81,10 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
msgCh := make(chan Message, 256) msgCh := make(chan Message, 256)
resCh := make(chan Result, 1) resCh := make(chan Result, 1)
// Close stderr when the context is cancelled so the scanner unblocks. // Close stdout when the context is cancelled so the scanner unblocks.
go func() { go func() {
<-runCtx.Done() <-runCtx.Done()
_ = stderr.Close() _ = stdout.Close()
}() }()
go func() { go func() {
@@ -90,7 +93,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
defer close(resCh) defer close(resCh)
startTime := time.Now() startTime := time.Now()
scanResult := b.processOutput(stderr, msgCh) scanResult := b.processOutput(stdout, msgCh)
// Wait for process exit. // Wait for process exit.
exitErr := cmd.Wait() exitErr := cmd.Wait()
@@ -202,9 +205,10 @@ type openclawEventResult struct {
model string model string
} }
// processOutput reads the JSON output from openclaw --json stderr and returns // processOutput reads the JSON output from openclaw --json stdout and returns
// the parsed result. OpenClaw writes its JSON output to stderr, which may also // the parsed result. OpenClaw writes its JSON output to stdout; stderr carries
// contain non-JSON log lines. The stream may contain: // log overflow and is captured separately by the caller. The stream may
// contain:
// //
// - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error", // - NDJSON streaming events (type: "text", "tool_use", "tool_result", "error",
// "step_start", "step_finish") — emitted in real time as the agent works // "step_start", "step_finish") — emitted in real time as the agent works
@@ -309,12 +313,12 @@ func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclaw
} }
// Not JSON — treat as log line. // Not JSON — treat as log line.
b.cfg.Logger.Debug("[openclaw:stderr] " + line) b.cfg.Logger.Debug("[openclaw:stdout] " + line)
rawLines = append(rawLines, line) rawLines = append(rawLines, line)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)} return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stdout: %v", err)}
} }
// If we got no events at all, fall back to raw output. // If we got no events at all, fall back to raw output.

View File

@@ -3,6 +3,7 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -234,8 +235,8 @@ func TestOpenclawProcessOutputReadError(t *testing.T) {
if res.status != "failed" { if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed") t.Errorf("status: got %q, want %q", res.status, "failed")
} }
if !strings.Contains(res.errMsg, "read stderr") { if !strings.Contains(res.errMsg, "read stdout") {
t.Errorf("errMsg: got %q, want it to contain 'read stderr'", res.errMsg) t.Errorf("errMsg: got %q, want it to contain 'read stdout'", res.errMsg)
} }
close(ch) close(ch)
@@ -1163,3 +1164,67 @@ func countOccurrences(args []string, s string) int {
} }
return n return n
} }
// TestOpenclawProcessOutputStdoutFixture is the regression test for WOR-10.
// It feeds a recorded `openclaw agent --local --json` blob (captured from
// openclaw 2026.5.5 at the time of the fix) into processOutput exactly as
// the swapped pipe would deliver it, and asserts the result + messages parse.
//
// Before the fix, the daemon read this same byte stream from stderr (where
// nothing was written), produced "openclaw returned no parseable output",
// and surfaced a system-typed comment to users. After the fix, processOutput
// reads from stdout and this fixture parses cleanly.
func TestOpenclawProcessOutputStdoutFixture(t *testing.T) {
t.Parallel()
data, err := os.ReadFile("testdata/openclaw-2026.5.5-stdout.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
if len(data) < 1000 {
t.Fatalf("fixture too small (%d bytes); did the file get truncated?", len(data))
}
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.errMsg != "" {
t.Errorf("errMsg: got %q, want empty", res.errMsg)
}
if res.output != "hi" {
t.Errorf("output: got %q, want %q", res.output, "hi")
}
if res.sessionID == "" {
t.Errorf("sessionID: got empty, want non-empty")
}
if res.model != "anthropic/claude-opus-4.7" {
t.Errorf("model: got %q, want %q", res.model, "anthropic/claude-opus-4.7")
}
if res.usage.InputTokens != 34620 {
t.Errorf("usage.InputTokens: got %d, want %d", res.usage.InputTokens, 34620)
}
if res.usage.OutputTokens != 6 {
t.Errorf("usage.OutputTokens: got %d, want %d", res.usage.OutputTokens, 6)
}
if res.usage.CacheWriteTokens != 46482 {
t.Errorf("usage.CacheWriteTokens: got %d, want %d", res.usage.CacheWriteTokens, 46482)
}
close(ch)
// At least one MessageText event should have been emitted carrying "hi".
var gotText bool
for msg := range ch {
if msg.Type == MessageText && strings.Contains(msg.Content, "hi") {
gotText = true
}
}
if !gotText {
t.Errorf("expected a MessageText event containing %q", "hi")
}
}

File diff suppressed because it is too large Load Diff