Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
815a84bb55 fix(agent): surface codex turn errors instead of reporting empty output
When codex emits `turn/completed` with `status="failed"` or a terminal
top-level `error` notification, the daemon previously treated the turn
as successfully completed, saw no accumulated text, and surfaced the
generic "codex returned empty output" — hiding the real reason (auth,
sandbox, API error, etc.).

Capture `turn.error.message` on failed turns and the `error.message`
from non-retrying top-level error notifications, then propagate them
through `Result.Error` with `finalStatus="failed"` so the daemon's
default branch reports the actual cause.
2026-04-16 16:35:25 +08:00
2 changed files with 127 additions and 1 deletions

View File

@@ -200,9 +200,15 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
// Wait for turn completion or context cancellation
select {
case aborted := <-turnDone:
if aborted {
switch {
case aborted:
finalStatus = "aborted"
finalError = "turn was aborted"
default:
if errMsg := c.getTurnError(); errMsg != "" {
finalStatus = "failed"
finalError = errMsg
}
}
case <-runCtx.Done():
if runCtx.Err() == context.DeadlineExceeded {
@@ -287,6 +293,26 @@ type codexClient struct {
usageMu sync.Mutex
usage TokenUsage // accumulated from turn events
turnErrorMu sync.Mutex
turnError string // captured from turn/completed status=failed or terminal error notifications
}
func (c *codexClient) setTurnError(msg string) {
if msg == "" {
return
}
c.turnErrorMu.Lock()
defer c.turnErrorMu.Unlock()
if c.turnError == "" {
c.turnError = msg
}
}
func (c *codexClient) getTurnError() string {
c.turnErrorMu.Lock()
defer c.turnErrorMu.Unlock()
return c.turnError
}
type pendingRPC struct {
@@ -567,6 +593,16 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any
aborted := status == "cancelled" || status == "canceled" ||
status == "aborted" || status == "interrupted"
// Capture the error message from failed turns so callers can surface
// a real reason instead of falling back to "empty output".
if status == "failed" {
errMsg := extractNestedString(params, "turn", "error", "message")
if errMsg == "" {
errMsg = "codex turn failed"
}
c.setTurnError(errMsg)
}
if c.completedTurnIDs == nil {
c.completedTurnIDs = map[string]bool{}
}
@@ -586,6 +622,22 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any
c.onTurnDone(aborted)
}
case "error":
// Top-level protocol error. Retrying notifications (willRetry=true) are
// transient reconnect attempts; only capture terminal errors so we
// don't stomp on a real failure later with a retry placeholder.
willRetry, _ := params["willRetry"].(bool)
errMsg := extractNestedString(params, "error", "message")
if errMsg == "" {
errMsg = extractNestedString(params, "message")
}
if errMsg != "" {
c.cfg.Logger.Warn("codex error notification", "message", errMsg, "will_retry", willRetry)
if !willRetry {
c.setTurnError(errMsg)
}
}
case "thread/status/changed":
statusType := extractNestedString(params, "status", "type")
if statusType == "idle" && c.turnStarted {

View File

@@ -349,6 +349,80 @@ func TestCodexRawTurnCompletedAborted(t *testing.T) {
}
}
func TestCodexRawTurnCompletedFailedCapturesError(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var wasAborted bool
c.onTurnDone = func(aborted bool) {
wasAborted = aborted
}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-f","status":"failed","error":{"message":"unexpected status 401 Unauthorized"}}}}`)
if wasAborted {
t.Fatal("failed is distinct from aborted")
}
if got := c.getTurnError(); got != "unexpected status 401 Unauthorized" {
t.Fatalf("expected error captured from turn.error.message, got %q", got)
}
}
func TestCodexRawTurnCompletedFailedWithoutMessageFallsBack(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
c.onTurnDone = func(aborted bool) {}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-f","status":"failed"}}}`)
if got := c.getTurnError(); got != "codex turn failed" {
t.Fatalf("expected fallback message, got %q", got)
}
}
func TestCodexRawErrorNotificationTerminal(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
c.handleLine(`{"jsonrpc":"2.0","method":"error","params":{"error":{"message":"boom"},"willRetry":false}}`)
if got := c.getTurnError(); got != "boom" {
t.Fatalf("expected terminal error captured, got %q", got)
}
}
func TestCodexRawErrorNotificationRetryingIgnored(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
c.handleLine(`{"jsonrpc":"2.0","method":"error","params":{"error":{"message":"reconnecting"},"willRetry":true}}`)
if got := c.getTurnError(); got != "" {
t.Fatalf("retrying error should not be captured, got %q", got)
}
}
func TestCodexSetTurnErrorFirstWins(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.setTurnError("first")
c.setTurnError("second")
if got := c.getTurnError(); got != "first" {
t.Fatalf("expected first-wins semantics, got %q", got)
}
}
func TestCodexRawItemCommandExecution(t *testing.T) {
t.Parallel()