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
8 changes: 8 additions & 0 deletions .github/workflows/issue-triage.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .github/workflows/test-claude.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .github/workflows/test-proxy.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .github/workflows/weekly-research.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions pkg/workflow/agentic_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type AgenticEngine interface {
// SupportsMaxTurns returns true if this engine supports the max-turns feature
SupportsMaxTurns() bool

// GetDeclaredOutputFiles returns a list of output files that this engine may produce
// These files will be automatically uploaded as artifacts if they exist
GetDeclaredOutputFiles() []string

// GetInstallationSteps returns the GitHub Actions steps needed to install this engine
GetInstallationSteps(engineConfig *EngineConfig) []GitHubActionStep

Expand Down Expand Up @@ -102,6 +106,11 @@ func (e *BaseEngine) SupportsMaxTurns() bool {
return e.supportsMaxTurns
}

// GetDeclaredOutputFiles returns an empty list by default (engines can override)
func (e *BaseEngine) GetDeclaredOutputFiles() []string {
return []string{}
}

// EngineRegistry manages available agentic engines
type EngineRegistry struct {
engines map[string]AgenticEngine
Expand Down
188 changes: 119 additions & 69 deletions pkg/workflow/agentic_output_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -15,7 +16,7 @@ func TestAgenticOutputCollection(t *testing.T) {
}
defer os.RemoveAll(tmpDir)

// Test case with agentic output collection
// Test case with agentic output collection for Claude engine
testContent := `---
on: push
permissions:
Expand Down Expand Up @@ -54,127 +55,176 @@ This workflow tests the agentic output collection functionality.

lockContent := string(content)

// Verify pre-step: Setup agentic output file step exists
// Verify GITHUB_AW_OUTPUT functionality (should be present for all engines)
if !strings.Contains(lockContent, "- name: Setup agent output") {
t.Error("Expected 'Setup agent output' step to be in generated workflow")
}

// Verify the step uses github-script and sets up the output file
if !strings.Contains(lockContent, "uses: actions/github-script@v7") {
t.Error("Expected github-script action to be used for output file setup")
if !strings.Contains(lockContent, "- name: Collect agent output") {
t.Error("Expected 'Collect agent output' step to be in generated workflow")
}

if !strings.Contains(lockContent, "- name: Upload agentic output file") {
t.Error("Expected 'Upload agentic output file' step to be in generated workflow")
}

if !strings.Contains(lockContent, "const outputFile = `/tmp/aw_output_${randomId}.txt`;") {
t.Error("Expected output file creation in setup step")
// Verify job output declaration for GITHUB_AW_OUTPUT
if !strings.Contains(lockContent, "outputs:\n output: ${{ steps.collect_output.outputs.output }}") {
t.Error("Expected job output declaration for 'output'")
}

if !strings.Contains(lockContent, "core.exportVariable('GITHUB_AW_OUTPUT', outputFile);") {
t.Error("Expected GITHUB_AW_OUTPUT environment variable to be set")
// Verify GITHUB_AW_OUTPUT is passed to Claude
if !strings.Contains(lockContent, "GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }}") {
t.Error("Expected GITHUB_AW_OUTPUT environment variable to be passed to engine")
}

// Verify prompt injection: Check for output instructions in the prompt
// Verify prompt contains output instructions
if !strings.Contains(lockContent, "**IMPORTANT**: If you need to provide output that should be captured as a workflow output variable, write it to the file") {
t.Error("Expected output instructions to be injected into prompt")
}

if !strings.Contains(lockContent, "\"${{ env.GITHUB_AW_OUTPUT }}\"") {
t.Error("Expected GITHUB_AW_OUTPUT environment variable reference in prompt")
// Verify Claude engine declared outputs are uploaded separately
if !strings.Contains(lockContent, "- name: Upload engine output files") {
t.Error("Expected 'Upload engine output files' step for Claude engine")
}

// Verify environment variable is passed to agentic engine
if !strings.Contains(lockContent, "claude_env: |\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GITHUB_AW_OUTPUT: ${{ env.GITHUB_AW_OUTPUT }}") {
t.Error("Expected GITHUB_AW_OUTPUT environment variable to be passed to Claude via claude_env")
if !strings.Contains(lockContent, "name: agent_outputs") {
t.Error("Expected engine output artifact to be named 'agent_outputs'")
}

// Verify post-step: Collect agentic output step exists
if !strings.Contains(lockContent, "- name: Collect agent output") {
t.Error("Expected 'Collect agent output' step to be in generated workflow")
// Verify that both artifacts are uploaded
if !strings.Contains(lockContent, fmt.Sprintf("name: %s", OutputArtifactName)) {
t.Errorf("Expected GITHUB_AW_OUTPUT artifact name to be '%s'", OutputArtifactName)
}

if !strings.Contains(lockContent, "id: collect_output") {
t.Error("Expected collect_output step ID")
t.Log("Claude workflow correctly includes both GITHUB_AW_OUTPUT and engine output collection")
}

func TestCodexEngineNoOutputSteps(t *testing.T) {
// Create temporary directory for test files
tmpDir, err := os.MkdirTemp("", "codex-no-output-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)

// Test case with Codex engine (should have GITHUB_AW_OUTPUT but no engine output collection)
testContent := `---
on: push
permissions:
contents: read
issues: write
tools:
github:
allowed: [list_issues]
engine: codex
---

# Test Codex No Engine Output Collection

This workflow tests that Codex engine gets GITHUB_AW_OUTPUT but not engine output collection.
`

if !strings.Contains(lockContent, "const outputFile = process.env.GITHUB_AW_OUTPUT;") {
t.Error("Expected output file reading in collection step")
testFile := filepath.Join(tmpDir, "test-codex-no-output.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

if !strings.Contains(lockContent, "core.setOutput('output', sanitizedContent);") {
t.Error("Expected sanitized output to be set in collection step")
compiler := NewCompiler(false, "", "test")

// Compile the workflow
err = compiler.CompileWorkflow(testFile)
if err != nil {
t.Fatalf("Unexpected error compiling workflow with Codex: %v", err)
}

// Verify sanitization function is included
if !strings.Contains(lockContent, "function sanitizeContent(content) {") {
t.Error("Expected sanitization function to be in collection step")
// Read the generated lock file
lockFile := filepath.Join(tmpDir, "test-codex-no-output.lock.yml")
content, err := os.ReadFile(lockFile)
if err != nil {
t.Fatalf("Failed to read generated lock file: %v", err)
}

if !strings.Contains(lockContent, "const sanitizedContent = sanitizeContent(outputContent);") {
t.Error("Expected sanitization function to be called on output content")
lockContent := string(content)

// Verify that Codex workflow DOES have GITHUB_AW_OUTPUT functionality
if !strings.Contains(lockContent, "- name: Setup agent output") {
t.Error("Codex workflow should have 'Setup agent output' step (GITHUB_AW_OUTPUT functionality)")
}

// Verify job output declaration
if !strings.Contains(lockContent, "outputs:\n output: ${{ steps.collect_output.outputs.output }}") {
t.Error("Expected job output declaration for 'output'")
if !strings.Contains(lockContent, "- name: Collect agent output") {
t.Error("Codex workflow should have 'Collect agent output' step (GITHUB_AW_OUTPUT functionality)")
}

// Verify artifact upload step: Upload agentic output file step exists
if !strings.Contains(lockContent, "- name: Upload agentic output file") {
t.Error("Expected 'Upload agentic output file' step to be in generated workflow")
t.Error("Codex workflow should have 'Upload agentic output file' step (GITHUB_AW_OUTPUT functionality)")
}

// Verify the upload step uses actions/upload-artifact@v4
if !strings.Contains(lockContent, "uses: actions/upload-artifact@v4") {
t.Error("Expected upload-artifact action to be used for artifact upload step")
if !strings.Contains(lockContent, "GITHUB_AW_OUTPUT") {
t.Error("Codex workflow should reference GITHUB_AW_OUTPUT environment variable")
}

// Verify the artifact upload configuration
if !strings.Contains(lockContent, "name: aw_output.txt") {
t.Error("Expected artifact name to be 'aw_output.txt'")
if !strings.Contains(lockContent, fmt.Sprintf("name: %s", OutputArtifactName)) {
t.Errorf("Codex workflow should reference %s artifact (GITHUB_AW_OUTPUT)", OutputArtifactName)
}

if !strings.Contains(lockContent, "path: ${{ env.GITHUB_AW_OUTPUT }}") {
t.Error("Expected artifact path to use GITHUB_AW_OUTPUT environment variable")
// Verify that job outputs section includes output for GITHUB_AW_OUTPUT
if !strings.Contains(lockContent, "outputs:\n output: ${{ steps.collect_output.outputs.output }}") {
t.Error("Codex workflow should have job output declaration for 'output' (GITHUB_AW_OUTPUT)")
}

if !strings.Contains(lockContent, "if-no-files-found: warn") {
t.Error("Expected if-no-files-found: warn configuration for artifact upload")
// Verify that Codex workflow does NOT have engine output collection steps
if strings.Contains(lockContent, "- name: Collect engine output files") {
t.Error("Codex workflow should NOT have 'Collect engine output files' step")
}

// Verify the upload step condition checks for non-empty output
if !strings.Contains(lockContent, "if: always() && steps.collect_output.outputs.output != ''") {
t.Error("Expected upload step to check for non-empty output from collection step")
if strings.Contains(lockContent, "- name: Upload engine output files") {
t.Error("Codex workflow should NOT have 'Upload engine output files' step")
}

// Verify step order: setup should come before agentic execution, collection should come after
setupIndex := strings.Index(lockContent, "- name: Setup agent output")
executeIndex := strings.Index(lockContent, "- name: Execute Claude Code Action")
collectIndex := strings.Index(lockContent, "- name: Collect agent output")
uploadIndex := strings.Index(lockContent, "- name: Upload agentic output file")

// If "Execute Claude Code" isn't found, try alternative step names
if executeIndex == -1 {
executeIndex = strings.Index(lockContent, "- name: Execute Claude")
}
if executeIndex == -1 {
executeIndex = strings.Index(lockContent, "uses: githubnext/claude-action")
if strings.Contains(lockContent, "name: agent_outputs") {
t.Error("Codex workflow should NOT reference 'agent_outputs' artifact")
}

if setupIndex == -1 || executeIndex == -1 || collectIndex == -1 || uploadIndex == -1 {
t.Fatal("Could not find expected steps in generated workflow")
// Verify that the Codex execution step is still present
if !strings.Contains(lockContent, "- name: Run Codex") {
t.Error("Expected 'Run Codex' step to be in generated workflow")
}

if setupIndex >= executeIndex {
t.Error("Setup step should appear before agentic execution step")
t.Log("Codex workflow correctly includes GITHUB_AW_OUTPUT functionality but excludes engine output collection")
}

func TestEngineOutputFileDeclarations(t *testing.T) {
// Test Claude engine declares output files
claudeEngine := NewClaudeEngine()
claudeOutputFiles := claudeEngine.GetDeclaredOutputFiles()

if len(claudeOutputFiles) == 0 {
t.Error("Claude engine should declare at least one output file")
}

if collectIndex <= executeIndex {
t.Error("Collection step should appear after agentic execution step")
if !stringSliceContains(claudeOutputFiles, "output.txt") {
t.Errorf("Claude engine should declare 'output.txt' as an output file, got: %v", claudeOutputFiles)
}

if uploadIndex <= collectIndex {
t.Error("Upload step should appear after collection step")
// Test Codex engine declares no output files
codexEngine := NewCodexEngine()
codexOutputFiles := codexEngine.GetDeclaredOutputFiles()

if len(codexOutputFiles) != 0 {
t.Errorf("Codex engine should declare no output files, got: %v", codexOutputFiles)
}

t.Logf("Step order verified: Setup (%d) < Execute (%d) < Collect (%d) < Upload (%d)",
setupIndex, executeIndex, collectIndex, uploadIndex)
t.Logf("Claude engine declares: %v", claudeOutputFiles)
t.Logf("Codex engine declares: %v", codexOutputFiles)
}

// Helper function to check if a string slice contains a specific string
func stringSliceContains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
5 changes: 5 additions & 0 deletions pkg/workflow/claude_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ func (e *ClaudeEngine) GetInstallationSteps(engineConfig *EngineConfig) []GitHub
return []GitHubActionStep{}
}

// GetDeclaredOutputFiles returns the output files that Claude may produce
func (e *ClaudeEngine) GetDeclaredOutputFiles() []string {
return []string{"output.txt"}
}

func (e *ClaudeEngine) GetExecutionConfig(workflowName string, logFile string, engineConfig *EngineConfig) ExecutionConfig {
// Determine the action version to use
actionVersion := DefaultClaudeActionVersion // Default version
Expand Down
Loading
Loading