diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-05 22:53:02 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-05 22:53:02 +0000 |
| commit | 4c0ee5c215b6b1965ee2ac30d9341f5e8fb6f569 (patch) | |
| tree | 63c2a13e48d5d1ce7781d34b63391c94792f8b46 /internal/executor/stream_test.go | |
| parent | 9e790e35708f834abe1a09af52e43742e164cb63 (diff) | |
executor: detect stream-level failures when claude exits 0
Rename streamAndParseCost → parseStream (returns float64, error).
Detect two failure modes that claude reports via exit 0:
- result message with is_error:true
- tool_result permission denial ("haven't granted it yet")
Also fix cost extraction to read total_cost_usd from the result
message (the actual field name), keeping cost_usd as legacy fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor/stream_test.go')
| -rw-r--r-- | internal/executor/stream_test.go | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/internal/executor/stream_test.go b/internal/executor/stream_test.go new file mode 100644 index 0000000..10eb858 --- /dev/null +++ b/internal/executor/stream_test.go @@ -0,0 +1,85 @@ +package executor + +import ( + "io" + "log/slog" + "strings" + "testing" +) + +// streamLine builds a single-line stream-json message. +func streamLine(json string) string { return json + "\n" } + +func TestParseStream_ResultIsError_ReturnsError(t *testing.T) { + input := streamLine(`{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something went wrong"}`) + _, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil { + t.Fatal("expected error when result.is_error=true, got nil") + } + if !strings.Contains(err.Error(), "something went wrong") { + t.Errorf("error should contain result text, got: %v", err) + } +} + +func TestParseStream_PermissionDenied_ReturnsError(t *testing.T) { + // Simulate the permission denial tool_result that Claude Code emits + // when permission_mode is not bypassPermissions. + input := streamLine(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","is_error":true,"content":"Claude requested permissions to write to /foo/bar.go, but you haven't granted it yet.","tool_use_id":"tu_abc"}]}}`) + + streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"I need permission","total_cost_usd":0.1}`) + + _, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err == nil { + t.Fatal("expected error for permission denial, got nil") + } + if !strings.Contains(err.Error(), "permission") { + t.Errorf("error should mention permission, got: %v", err) + } +} + +func TestParseStream_Success_ReturnsNilError(t *testing.T) { + input := streamLine(`{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}}`) + + streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"All tests pass.","total_cost_usd":0.05}`) + + _, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("expected nil error for success stream, got: %v", err) + } +} + +func TestParseStream_ExtractsCostFromResultMessage(t *testing.T) { + input := streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":1.2345}`) + + cost, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cost != 1.2345 { + t.Errorf("want cost 1.2345, got %f", cost) + } +} + +func TestParseStream_ExtractsCostFromLegacyCostUSD(t *testing.T) { + // Some versions emit cost_usd at the top level rather than total_cost_usd. + input := streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"done","cost_usd":0.99}`) + + cost, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cost != 0.99 { + t.Errorf("want cost 0.99, got %f", cost) + } +} + +func TestParseStream_NonToolResultIsError_DoesNotFail(t *testing.T) { + // A tool_result with is_error:true that is NOT a permission denial + // (e.g. the tool ran but the command failed) should not mark the task failed — + // the agent can recover from individual tool errors. + input := streamLine(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","is_error":true,"content":"exit status 1","tool_use_id":"tu_xyz"}]}}`) + + streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"Fixed it.","total_cost_usd":0.2}`) + + _, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("non-permission tool errors should not fail the task, got: %v", err) + } +} |
