v0.15.0 6b178f8

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 TestTurnResultValidatesShutdownOrigin(t *testing.T) {
37	result := &TurnResult{
38		Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultCanceled,
39		ShutdownOrigin: ShutdownOriginSession,
40	}
41	if err := result.Validate(0, 0); err != nil {
42		t.Fatalf("known shutdown origin was rejected: %v", err)
43	}
44	result.ShutdownOrigin = ShutdownOrigin("private detail")
45	if err := result.Validate(0, 0); err == nil {
46		t.Fatal("unknown shutdown origin was accepted")
47	}
48}
49
50func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
51	result := &TurnResult{
52		Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,
53		Output: "😀\n第二行\n第三行",
54	}
55	bounded := result.Bounded(10, 2)
56	if countLines(bounded.Output) > 2 {
57		t.Fatalf("bounded lines = %d: %q", countLines(bounded.Output), bounded.Output)
58	}
59	if len([]byte(bounded.Output)) > 10 {
60		t.Fatalf("bounded bytes = %d: %q", len([]byte(bounded.Output)), bounded.Output)
61	}
62	if !utf8.ValidString(bounded.Output) {
63		t.Fatal("bounded output is not valid UTF-8")
64	}
65}
66
67func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) {
68	invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'})
69	tests := []struct {
70		name     string
71		value    string
72		maxBytes int
73		want     string
74	}{
75		{
76			name:     "multibyte",
77			value:    "prefix😀suffix",
78			maxBytes: len([]byte("prefix")) + 2,
79			want:     "prefix",
80		},
81		{
82			name:     "invalid byte before incomplete rune",
83			value:    invalidPrefix + string([]byte{0xe2, 0x82}) + "tail",
84			maxBytes: len([]byte(invalidPrefix)) + 2,
85			want:     invalidPrefix,
86		},
87		{
88			name:     "invalid byte before ascii cut",
89			value:    invalidPrefix + "😀",
90			maxBytes: len([]byte(invalidPrefix)),
91			want:     invalidPrefix,
92		},
93		{
94			name:     "invalid trailing byte is preserved",
95			value:    string([]byte{'a', 0xff, 0x80}) + "😀",
96			maxBytes: 3,
97			want:     string([]byte{'a', 0xff, 0x80}),
98		},
99	}
100	for _, tt := range tests {
101		t.Run(tt.name, func(t *testing.T) {
102			if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want {
103				t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want)
104			}
105		})
106	}
107}
108
109func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
110	invalid := NewEvent(EventTurnResult, map[string]any{
111		"status":  "unknown",
112		"turn_id": "turn-1",
113	})
114	if _, err := decodeTurnResultEvent(invalid, "agent-1", 100, 10); err == nil {
115		t.Fatal("invalid result status was accepted")
116	}
117	mismatched := NewEvent(EventTurnResult, map[string]any{
118		"agent_id": "other",
119		"status":   string(ResultSucceeded),
120		"turn_id":  "turn-1",
121	})
122	if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {
123		t.Fatal("mismatched result agent was accepted")
124	}
125	valid := NewEvent(EventTurnResult, map[string]any{
126		"status":          string(ResultCanceled),
127		"shutdown_origin": ShutdownOriginSession,
128	})
129	valid.AgentID = "agent-1"
130	valid.TurnID = "turn-1"
131	result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)
132	if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" || result.ShutdownOrigin != ShutdownOriginSession {
133		t.Fatalf("valid result = %#v, err=%v", result, err)
134	}
135}
136
137func TestTurnResultReferencesAndPersistence(t *testing.T) {
138	if got := AgentRef("a"); got != "subagent://a" {
139		t.Fatal(got)
140	}
141	if got := HistoryRef("a"); got != "subagent://a/history" {
142		t.Fatal(got)
143	}
144	if got := ResultRef("a"); got != "subagent://a/result" {
145		t.Fatal(got)
146	}
147	if got := PatchRef("a"); got != "subagent://a/patch" {
148		t.Fatal(got)
149	}
150	state := t.TempDir()
151	want := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultFailed, Error: &ResultError{Code: "x", Message: "failed"}}
152	if err := writeTurnResult(state, want); err != nil {
153		t.Fatal(err)
154	}
155	got, err := readTurnResult(state)
156	if err != nil {
157		t.Fatal(err)
158	}
159	if got.Status != want.Status || got.Error == nil || got.Error.Code != "x" {
160		t.Fatalf("got = %#v", got)
161	}
162	info, err := os.Stat(filepath.Join(state, "result.json"))
163	if err != nil {
164		t.Fatal(err)
165	}
166	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
167		t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
168	}
169}
170
171func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
172	state := t.TempDir()
173	if err := os.Mkdir(resultPath(state), 0o700); err != nil {
174		t.Fatal(err)
175	}
176	result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded}
177	if err := writeTurnResult(state, result); err == nil {
178		t.Fatal("writeTurnResult succeeded when the result path was a directory")
179	}
180	if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) {
181		t.Fatalf("temporary result file still exists, stat error = %v", err)
182	}
183}
184
185func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
186	makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
187		return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
188	}
189	assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) {
190		t.Helper()
191		supervisor.captureWorkspace(agent)
192		supervisor.ensureResult(agent, StatusDone, nil)
193		checkCaptureFailureResult(t, agent, stateDir)
194	}
195
196	t.Run("stat", func(t *testing.T) {
197		stateDir := filepath.Join(t.TempDir(), "missing")
198		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
199			t.Fatal("workspace capture should not run when the state directory cannot be stat'ed")
200			return WorkspaceCapture{}, nil
201		})
202		assertFailure(t, &Supervisor{}, agent, stateDir)
203	})
204
205	t.Run("capture", func(t *testing.T) {
206		stateDir := t.TempDir()
207		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
208			return WorkspaceCapture{}, errors.New("capture leaked /private/secret")
209		})
210		assertFailure(t, &Supervisor{}, agent, stateDir)
211	})
212
213	t.Run("temp write", func(t *testing.T) {
214		stateDir := t.TempDir()
215		if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil {
216			t.Fatal(err)
217		}
218		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
219			return WorkspaceCapture{Patch: []byte("patch")}, nil
220		})
221		assertFailure(t, &Supervisor{}, agent, stateDir)
222	})
223
224	t.Run("rename", func(t *testing.T) {
225		stateDir := t.TempDir()
226		if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil {
227			t.Fatal(err)
228		}
229		agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
230			return WorkspaceCapture{Patch: []byte("patch")}, nil
231		})
232		assertFailure(t, &Supervisor{}, agent, stateDir)
233		if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) {
234			t.Fatalf("workspace temporary patch still exists, stat error = %v", err)
235		}
236	})
237}
238
239func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) {
240	stateDir := t.TempDir()
241	agent := &Agent{
242		ID:       "agent-1",
243		stateDir: stateDir,
244		workspaceCapture: func() (WorkspaceCapture, error) {
245			return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil
246		},
247	}
248	(&Supervisor{}).captureWorkspace(agent)
249
250	patch, err := os.ReadFile(patchPath(stateDir))
251	if err != nil {
252		t.Fatal(err)
253	}
254	if string(patch) != "patch" {
255		t.Fatalf("patch = %q, want %q", patch, "patch")
256	}
257	agent.lifecycleMu.Lock()
258	patchRef := agent.patchRef
259	changedFiles := append([]string(nil), agent.changedFiles...)
260	agent.lifecycleMu.Unlock()
261	if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" {
262		t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles)
263	}
264}
265
266func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
267	t.Helper()
268	result := agent.Result()
269	if result == nil || result.Status != ResultFailed || result.Error == nil {
270		t.Fatalf("capture failure result = %#v", result)
271	}
272	if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage {
273		t.Fatalf("capture failure error = %#v", result.Error)
274	}
275	if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") {
276		t.Fatalf("capture failure leaked private data: %#v", result.Error)
277	}
278	durable, err := readTurnResult(stateDir)
279	if err != nil {
280		t.Fatal(err)
281	}
282	if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed {
283		t.Fatalf("durable capture failure = %#v", durable)
284	}
285}
286
287func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
288	tests := []struct {
289		name               string
290		status             Status
291		runErr             error
292		childCode          string
293		shutdownOrigin     ShutdownOrigin
294		wantShutdownOrigin ShutdownOrigin
295		wantState          TurnStatus
296		wantCode           string
297		wantOutput         string
298	}{
299		{name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
300		{name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
301		{name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantShutdownOrigin: ShutdownOriginDeadline, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
302		{name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantShutdownOrigin: ShutdownOriginDeadline, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
303		{name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
304		{name: "session shutdown", status: StatusKilled, runErr: context.Canceled, shutdownOrigin: ShutdownOriginSession, wantShutdownOrigin: ShutdownOriginSession, wantState: ResultCanceled, wantCode: "shutdown"},
305	}
306	for _, tt := range tests {
307		t.Run(tt.name, func(t *testing.T) {
308			stateDir := t.TempDir()
309			childStatus := ResultSucceeded
310			var childError *ResultError
311			if tt.childCode != "" {
312				childStatus = ResultFailed
313				childError = &ResultError{Code: tt.childCode, Message: "child error"}
314			}
315			agent := &Agent{
316				ID:             "agent-1",
317				stateDir:       stateDir,
318				lastAssistant:  tt.wantOutput,
319				shutdownOrigin: tt.shutdownOrigin,
320				result: &TurnResult{
321					AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
322				},
323			}
324			supervisor := &Supervisor{}
325			supervisor.ensureResult(agent, tt.status, tt.runErr)
326			result := agent.Result()
327			if result == nil || result.Status != tt.wantState {
328				t.Fatalf("result status = %#v, want %q", result, tt.wantState)
329			}
330			if result.Error == nil {
331				t.Fatal("runner error was not recorded")
332			}
333			if result.Error.Code != tt.wantCode {
334				t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
335			}
336			if result.Output != tt.wantOutput {
337				t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
338			}
339			durable, err := readTurnResult(stateDir)
340			if err != nil {
341				t.Fatal(err)
342			}
343			if durable.Status != tt.wantState {
344				t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
345			}
346			if durable.Output != tt.wantOutput {
347				t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
348			}
349			if durable.Error == nil || durable.Error.Code != tt.wantCode {
350				t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
351			}
352			if durable.ShutdownOrigin != tt.wantShutdownOrigin {
353				t.Fatalf("durable shutdown origin = %q, want %q", durable.ShutdownOrigin, tt.wantShutdownOrigin)
354			}
355			if tt.wantShutdownOrigin != "" {
356				wantErr := ShutdownResultError(tt.wantShutdownOrigin)
357				if *durable.Error != *wantErr {
358					t.Fatalf("durable result error = %#v, want canonical %#v", durable.Error, wantErr)
359				}
360			}
361		})
362	}
363}
364
365func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
366	stateDir := t.TempDir()
367	agent := &Agent{
368		ID:            "agent-1",
369		stateDir:      stateDir,
370		lastAssistant: "streamed text",
371		result: &TurnResult{
372			AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
373		},
374	}
375
376	(&Supervisor{}).ensureResult(agent, StatusDone, nil)
377
378	if result := agent.Result(); result == nil || result.Output != "" {
379		t.Fatalf("successful result = %#v, want intentionally empty output", result)
380	}
381}

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 477

↑↓ moveenter openesc close