Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions pkg/cli/trial_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
}

// Save individual workflow results
safeOutputErrors := extractSafeOutputErrors(artifacts.SafeOutputs)
result := WorkflowTrialResult{
WorkflowName: parsedSpec.WorkflowName,
RunID: runID,
Expand All @@ -130,6 +131,8 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
AgenticRunInfo: artifacts.AgenticRunInfo,
AdditionalArtifacts: artifacts.AdditionalArtifacts,
Timestamp: time.Now(),
Success: len(safeOutputErrors) == 0,
SafeOutputErrors: safeOutputErrors,
Comment on lines +134 to +135

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in latest commit: when --json is set, executeTrialRun now marshals the full WorkflowTrialResult (and CombinedTrialResult for multi-workflow runs) to stdout instead of the raw safe-outputs artifact, so success/safe_output_errors are exposed.

}
workflowResults = append(workflowResults, result)

Expand All @@ -140,16 +143,40 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save individual trial result: %v", err)))
}

// Display safe outputs to stdout
if len(artifacts.SafeOutputs) > 0 {
outputBytes, _ := json.MarshalIndent(artifacts.SafeOutputs, "", " ")
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("=== Safe Outputs from %s ===", parsedSpec.WorkflowName)))
fmt.Fprintln(os.Stdout, string(outputBytes))
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("=== End of Safe Outputs ==="))
// Display results to stdout. In JSON mode, emit the full WorkflowTrialResult
// (including Success/SafeOutputErrors) instead of the raw safe-outputs artifact,
// so consumers of `--json` see the pass/fail signal without inspecting nested
// "errors" arrays.
if opts.JSONOutput {
resultBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal trial result for '%s': %v", parsedSpec.WorkflowName, err)))
} else {
fmt.Fprintln(os.Stdout, string(resultBytes))
}
} else if len(artifacts.SafeOutputs) > 0 {
outputBytes, err := json.MarshalIndent(artifacts.SafeOutputs, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal safe outputs for '%s': %v", parsedSpec.WorkflowName, err)))
} else {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("=== Safe Outputs from %s ===", parsedSpec.WorkflowName)))
fmt.Fprintln(os.Stdout, string(outputBytes))
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("=== End of Safe Outputs ==="))
}
} else {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== No Safe Outputs Generated by %s ===", parsedSpec.WorkflowName)))
}

// Report rejected safe-output messages, if any. Messages may contain
// agent-controlled content, so control characters are sanitized before being
// written to the terminal/CI logs to avoid escape-sequence injection.
if len(safeOutputErrors) > 0 {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("=== %d Safe Output Message(s) Rejected from %s ===", len(safeOutputErrors), parsedSpec.WorkflowName)))
for _, msg := range safeOutputErrors {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(sanitizeControlChars(msg)))
}
}

// Display additional artifact information if available
// if len(artifacts.AgentStdioLogs) > 0 {
// fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== Agent Stdio Logs Available from %s (%d files) ===", parsedSpec.WorkflowName, len(artifacts.AgentStdioLogs))))
Expand All @@ -165,6 +192,8 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
}

// Step 6: Save combined results for multi-workflow trials
overallSuccess, totalRejected, firstErrorMessage := aggregateTrialResults(workflowResults)

if len(parsedSpecs) > 1 {
workflowNames := sliceutil.Map(parsedSpecs, func(spec *WorkflowSpec) string { return spec.WorkflowName })
workflowNamesStr := strings.Join(workflowNames, "-")
Expand All @@ -174,11 +203,21 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
WorkflowNames: workflowNames,
Results: workflowResults,
Timestamp: time.Now(),
Success: overallSuccess,
}
if err := saveTrialResult(combinedFilename, combinedResult, opts.Verbose); err != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save combined trial result: %v", err)))
}
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Combined results saved to: "+combinedFilename))

if opts.JSONOutput {
combinedBytes, err := json.MarshalIndent(combinedResult, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to marshal combined trial result: %v", err)))
} else {
fmt.Fprintln(os.Stdout, string(combinedBytes))
}
}
}

// Step 6.5: Copy trial results to host repository and commit them
Expand All @@ -187,6 +226,12 @@ func executeTrialRun(ctx context.Context, parsedSpecs []*WorkflowSpec, hostRepoS
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to copy trial results to repository: %v", err)))
}

if !overallSuccess {
sanitizedFirstError := sanitizeControlChars(firstErrorMessage)
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("Trial completed with %d rejected safe-output message(s)", totalRejected)))
return fmt.Errorf("trial completed with %d rejected safe-output message(s); first error: %s", totalRejected, sanitizedFirstError)
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("All trials completed successfully"))
return nil
}
Expand Down
197 changes: 197 additions & 0 deletions pkg/cli/trial_safe_output_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package cli

import "testing"

func TestExtractSafeOutputErrors(t *testing.T) {
tests := []struct {
name string
safeOutputs map[string]any
want []string
}{
{
name: "nil safe outputs",
safeOutputs: nil,
want: nil,
},
{
name: "no errors key",
safeOutputs: map[string]any{"items": []any{}},
want: nil,
},
{
name: "empty errors array",
safeOutputs: map[string]any{"items": []any{}, "errors": []any{}},
want: nil,
},
{
name: "non-empty errors array",
safeOutputs: map[string]any{
"items": []any{},
"errors": []any{"Line 1: set_issue_field requires at least one of: 'field_name', 'field_node_id' fields"},
},
want: []string{"Line 1: set_issue_field requires at least one of: 'field_name', 'field_node_id' fields"},
},
{
name: "multiple errors",
safeOutputs: map[string]any{
"errors": []any{"error one", "error two"},
},
want: []string{"error one", "error two"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractSafeOutputErrors(tt.safeOutputs)
if len(got) != len(tt.want) {
t.Fatalf("extractSafeOutputErrors() = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("extractSafeOutputErrors()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}

func TestWorkflowTrialResultSuccessField(t *testing.T) {
result := WorkflowTrialResult{
WorkflowName: "test-workflow",
SafeOutputErrors: []string{
"Line 1: some validation error",
},
Success: false,
}
if result.Success {
t.Error("expected Success to be false when SafeOutputErrors is non-empty")
Comment on lines +64 to +67

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factored the aggregation logic into aggregateTrialResults(results []WorkflowTrialResult) (bool, int, string), now used by executeTrialRun, and added TestAggregateTrialResults covering success/failure counts and first-error ordering across multiple workflows.

}

successResult := WorkflowTrialResult{
WorkflowName: "test-workflow",
Success: true,
}
if !successResult.Success {
t.Error("expected Success to be true when there are no safe-output errors")
}
}

func TestAggregateTrialResults(t *testing.T) {
tests := []struct {
name string
results []WorkflowTrialResult
wantSuccess bool
wantTotalRejected int
wantFirstError string
}{
{
name: "no results",
results: nil,
wantSuccess: true,
wantTotalRejected: 0,
wantFirstError: "",
},
{
name: "all successful",
results: []WorkflowTrialResult{
{WorkflowName: "a", Success: true},
{WorkflowName: "b", Success: true},
},
wantSuccess: true,
wantTotalRejected: 0,
wantFirstError: "",
},
{
name: "one failure with rejected messages",
results: []WorkflowTrialResult{
{WorkflowName: "a", Success: true},
{
WorkflowName: "b",
Success: false,
SafeOutputErrors: []string{"first error", "second error"},
},
},
wantSuccess: false,
wantTotalRejected: 2,
wantFirstError: "first error",
},
{
name: "multiple failures aggregate total and keep first error in order",
results: []WorkflowTrialResult{
{
WorkflowName: "a",
Success: false,
SafeOutputErrors: []string{"error from a"},
},
{
WorkflowName: "b",
Success: false,
SafeOutputErrors: []string{"error from b1", "error from b2"},
},
},
wantSuccess: false,
wantTotalRejected: 3,
wantFirstError: "error from a",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSuccess, gotTotalRejected, gotFirstError := aggregateTrialResults(tt.results)
if gotSuccess != tt.wantSuccess {
t.Errorf("aggregateTrialResults() success = %v, want %v", gotSuccess, tt.wantSuccess)
}
if gotTotalRejected != tt.wantTotalRejected {
t.Errorf("aggregateTrialResults() totalRejected = %d, want %d", gotTotalRejected, tt.wantTotalRejected)
}
if gotFirstError != tt.wantFirstError {
t.Errorf("aggregateTrialResults() firstErrorMessage = %q, want %q", gotFirstError, tt.wantFirstError)
}
})
}
}

func TestSanitizeControlChars(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty string", in: "", want: ""},
{name: "plain text unchanged", in: "plain error message", want: "plain error message"},
{
name: "escapes ANSI escape sequence",
in: "before\x1b[31mred\x1b[0mafter",
want: `before'\x1b'[31mred'\x1b'[0mafter`,
},
{
name: "escapes newline and tab",
in: "line1\nline2\ttabbed",
want: `line1'\n'line2'\t'tabbed`,
},
{
name: "escapes carriage return",
in: "before\rafter",
want: `before'\r'after`,
},
{
name: "escapes C1 control character",
in: "before\u009bafter",
want: `before'\u009b'after`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeControlChars(tt.in)
if got != tt.want {
t.Errorf("sanitizeControlChars(%q) = %q, want %q", tt.in, got, tt.want)
}
for _, r := range got {
if isControlRune(r) {
t.Errorf("sanitizeControlChars(%q) result %q still contains raw control character", tt.in, got)
}
}
})
}
}
Loading
Loading