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 childCode string
276 wantState TurnStatus
277 wantCode string
278 wantOutput string
279 }{
280 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
281 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
282 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283 {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
284 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
285 }
286 for _, tt := range tests {
287 t.Run(tt.name, func(t *testing.T) {
288 stateDir := t.TempDir()
289 childStatus := ResultSucceeded
290 var childError *ResultError
291 if tt.childCode != "" {
292 childStatus = ResultFailed
293 childError = &ResultError{Code: tt.childCode, Message: "child error"}
294 }
295 agent := &Agent{
296 ID: "agent-1",
297 stateDir: stateDir,
298 lastAssistant: tt.wantOutput,
299 result: &TurnResult{
300 AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
301 },
302 }
303 supervisor := &Supervisor{}
304 supervisor.ensureResult(agent, tt.status, tt.runErr)
305 result := agent.Result()
306 if result == nil || result.Status != tt.wantState {
307 t.Fatalf("result status = %#v, want %q", result, tt.wantState)
308 }
309 if result.Error == nil {
310 t.Fatal("runner error was not recorded")
311 }
312 if result.Error.Code != tt.wantCode {
313 t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
314 }
315 if result.Output != tt.wantOutput {
316 t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
317 }
318 durable, err := readTurnResult(stateDir)
319 if err != nil {
320 t.Fatal(err)
321 }
322 if durable.Status != tt.wantState {
323 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
324 }
325 if durable.Output != tt.wantOutput {
326 t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
327 }
328 if durable.Error == nil || durable.Error.Code != tt.wantCode {
329 t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
330 }
331 })
332 }
333}
334
335func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
336 stateDir := t.TempDir()
337 agent := &Agent{
338 ID: "agent-1",
339 stateDir: stateDir,
340 lastAssistant: "streamed text",
341 result: &TurnResult{
342 AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
343 },
344 }
345
346 (&Supervisor{}).ensureResult(agent, StatusDone, nil)
347
348 if result := agent.Result(); result == nil || result.Output != "" {
349 t.Fatalf("successful result = %#v, want intentionally empty output", result)
350 }
351}