summaryrefslogtreecommitdiff
path: root/internal/executor/stream_test.go
blob: 11a6178d4b2fa38d7227827a62b1553b21b5093d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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)
	}
}

func TestParseStream_ExtractsSessionID(t *testing.T) {
	input := streamLine(`{"type":"system","subtype":"init","session_id":"sess-999"}`) +
		streamLine(`{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.01}`)

	_, sid, err := parseStream(strings.NewReader(input), io.Discard, slog.New(slog.NewTextHandler(io.Discard, nil)))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if sid != "sess-999" {
		t.Errorf("want session ID sess-999, got %q", sid)
	}
}