v0.2.0 42a5079

result_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strings"
11	"testing"
12	"unicode/utf8"
13)
14
15func TestTurnResultValidationAndBoundedOutput(t *testing.T) {
16	result := &TurnResult{
17		Version:    ProtocolVersion,
18		AgentID:    "agent-1",
19		TurnID:     "turn-1",
20		Status:     ResultSucceeded,
21		Output:     "one\ntwo\nthree",
22		Structured: json.RawMessage(`{"ok":true}`),
23	}
24	if err := result.Validate(100, 3); err != nil {
25		t.Fatal(err)
26	}
27	bounded := result.Bounded(32, 2)
28	if bounded == result || !strings.Contains(bounded.Output, "truncated") {
29		t.Fatalf("bounded result = %#v", bounded)
30	}
31	if result.Output != "one\ntwo\nthree" {
32		t.Fatal("Bounded mutated the original result")
33	}
34}
35
36func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
37	result := &TurnResult{
38		Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,
39		Output: "😀\n第二行\n第三行",
40	}
41	bounded := result.Bounded(10, 2)
42	if countLines(bounded.Output) > 2 {
43		t.Fatalf("bounded lines = %d: %q", countLines(bounded.Output), bounded.Output)
44	}
45	if len([]byte(bounded.Output)) > 10 {
46		t.Fatalf("bounded bytes = %d: %q", len([]byte(bounded.Output)), bounded.Output)
47	}
48	if !utf8.ValidString(bounded.Output) {
49		t.Fatal("bounded output is not valid UTF-8")
50	}
51}
52
53func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) {
54	invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'})
55	tests := []struct {
56		name     string
57		value    string
58		maxBytes int
59		want     string
60	}{
61		{
62			name:     "multibyte",
63			value:    "prefix😀suffix",
64			maxBytes: len([]byte("prefix")) + 2,
65			want:     "prefix",
66		},
67		{
68			name:     "invalid byte before incomplete rune",
69			value:    invalidPrefix + string([]byte{0xe2, 0x82}) + "tail",
70			maxBytes: len([]byte(invalidPrefix)) + 2,
71			want:     invalidPrefix,
72		},
73		{
74			name:     "invalid byte before ascii cut",
75			value:    invalidPrefix + "😀",
76			maxBytes: len([]byte(invalidPrefix)),
77			want:     invalidPrefix,
78		},
79		{
80			name:     "invalid trailing byte is preserved",
81			value:    string([]byte{'a', 0xff, 0x80}) + "😀",
82			maxBytes: 3,
83			want:     string([]byte{'a', 0xff, 0x80}),
84		},
85	}
86	for _, tt := range tests {
87		t.Run(tt.name, func(t *testing.T) {
88			if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want {
89				t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want)
90			}
91		})
92	}
93}
94
95func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
96	invalid := NewEvent(EventTurnResult, map[string]any{
97		"status":  "unknown",
98		"turn_id": "turn-1",
99	})
100	if _, err := decodeTurnResultEvent(invalid, "agent-1", 100, 10); err == nil {
101		t.Fatal("invalid result status was accepted")
102	}
103	mismatched := NewEvent(EventTurnResult, map[string]any{
104		"agent_id": "other",
105		"status":   string(ResultSucceeded),
106		"turn_id":  "turn-1",
107	})
108	if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {
109		t.Fatal("mismatched result agent was accepted")
110	}
111	valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)})
112	valid.AgentID = "agent-1"
113	valid.TurnID = "turn-1"
114	result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)
115	if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" {
116		t.Fatalf("valid result = %#v, err=%v", result, err)
117	}
118}
119
120func TestTurnResultReferencesAndPersistence(t *testing.T) {
121	if got := AgentRef("a"); got != "subagent://a" {
122		t.Fatal(got)
123	}
124	if got := HistoryRef("a"); got != "subagent://a/history" {
125		t.Fatal(got)
126	}
127	if got := ResultRef("a"); got != "subagent://a/result" {
128		t.Fatal(got)
129	}
130	if got := PatchRef("a"); got != "subagent://a/patch" {
131		t.Fatal(got)
132	}
133	state := t.TempDir()
134	want := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultFailed, Error: &ResultError{Code: "x", Message: "failed"}}
135	if err := writeTurnResult(state, want); err != nil {
136		t.Fatal(err)
137	}
138	got, err := readTurnResult(state)
139	if err != nil {
140		t.Fatal(err)
141	}
142	if got.Status != want.Status || got.Error == nil || got.Error.Code != "x" {
143		t.Fatalf("got = %#v", got)
144	}
145	info, err := os.Stat(filepath.Join(state, "result.json"))
146	if err != nil {
147		t.Fatal(err)
148	}
149	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
150		t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
151	}
152}
153
154func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
155	state := t.TempDir()
156	if err := os.Mkdir(resultPath(state), 0o700); err != nil {
157		t.Fatal(err)
158	}
159	result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded}
160	if err := writeTurnResult(state, result); err == nil {
161		t.Fatal("writeTurnResult succeeded when the result path was a directory")
162	}
163	if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) {
164		t.Fatalf("temporary result file still exists, stat error = %v", err)
165	}
166}
167
168func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
169	makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
170		return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
171	}
172	assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) {
173		t.Helper()
174		supervisor.captureWorkspace(agent)
175		supervisor.ensureResult(agent, StatusDone, nil)
176		checkCaptureFailureResult(t, agent, stateDir)
177	}
178
179	t.Run("stat", func(t *testing.T) {
180		stateDir := filepath.Join(t.TempDir(), "missing")
181		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
182			t.Fatal("workspace capture should not run when the state directory cannot be stat'ed")
183			return WorkspaceCapture{}, nil
184		})
185		assertFailure(t, &Supervisor{}, agent, stateDir)
186	})
187
188	t.Run("capture", func(t *testing.T) {
189		stateDir := t.TempDir()
190		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
191			return WorkspaceCapture{}, errors.New("capture leaked /private/secret")
192		})
193		assertFailure(t, &Supervisor{}, agent, stateDir)
194	})
195
196	t.Run("temp write", func(t *testing.T) {
197		stateDir := t.TempDir()
198		if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil {
199			t.Fatal(err)
200		}
201		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
202			return WorkspaceCapture{Patch: []byte("patch")}, nil
203		})
204		assertFailure(t, &Supervisor{}, agent, stateDir)
205	})
206
207	t.Run("rename", func(t *testing.T) {
208		stateDir := t.TempDir()
209		if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil {
210			t.Fatal(err)
211		}
212		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
213			return WorkspaceCapture{Patch: []byte("patch")}, nil
214		})
215		assertFailure(t, &Supervisor{}, agent, stateDir)
216		if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) {
217			t.Fatalf("workspace temporary patch still exists, stat error = %v", err)
218		}
219	})
220}
221
222func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) {
223	stateDir := t.TempDir()
224	agent := &Agent{
225		ID:       "agent-1",
226		stateDir: stateDir,
227		workspaceCapture: func() (WorkspaceCapture, error) {
228			return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil
229		},
230	}
231	(&Supervisor{}).captureWorkspace(agent)
232
233	patch, err := os.ReadFile(patchPath(stateDir))
234	if err != nil {
235		t.Fatal(err)
236	}
237	if string(patch) != "patch" {
238		t.Fatalf("patch = %q, want %q", patch, "patch")
239	}
240	agent.lifecycleMu.Lock()
241	patchRef := agent.patchRef
242	changedFiles := append([]string(nil), agent.changedFiles...)
243	agent.lifecycleMu.Unlock()
244	if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" {
245		t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles)
246	}
247}
248
249func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
250	t.Helper()
251	result := agent.Result()
252	if result == nil || result.Status != ResultFailed || result.Error == nil {
253		t.Fatalf("capture failure result = %#v", result)
254	}
255	if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage {
256		t.Fatalf("capture failure error = %#v", result.Error)
257	}
258	if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") {
259		t.Fatalf("capture failure leaked private data: %#v", result.Error)
260	}
261	durable, err := readTurnResult(stateDir)
262	if err != nil {
263		t.Fatal(err)
264	}
265	if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed {
266		t.Fatalf("durable capture failure = %#v", durable)
267	}
268}
269
270func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271	tests := []struct {
272		name      string
273		status    Status
274		runErr    error
275		wantState TurnStatus
276	}{
277		{name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
278		{name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
279		{name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
280	}
281	for _, tt := range tests {
282		t.Run(tt.name, func(t *testing.T) {
283			stateDir := t.TempDir()
284			agent := &Agent{
285				ID:       "agent-1",
286				stateDir: stateDir,
287				result: &TurnResult{
288					AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
289				},
290			}
291			supervisor := &Supervisor{}
292			supervisor.ensureResult(agent, tt.status, tt.runErr)
293			result := agent.Result()
294			if result == nil || result.Status != tt.wantState {
295				t.Fatalf("result status = %#v, want %q", result, tt.wantState)
296			}
297			if result.Error == nil {
298				t.Fatal("runner error was not recorded")
299			}
300			durable, err := readTurnResult(stateDir)
301			if err != nil {
302				t.Fatal(err)
303			}
304			if durable.Status != tt.wantState {
305				t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306			}
307		})
308	}
309}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close