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}