From bdfe27ae08daa0d0e896240840c3261bf7713f35 Mon Sep 17 00:00:00 2001 From: Dmitry Kozlov Date: Sat, 30 May 2026 16:27:24 -0700 Subject: [PATCH] feat: add Codex and Claude Code components Signed-off-by: Dmitry Kozlov --- Dockerfile | 1 + docs/components/Claude.mdx | 60 +++ docs/components/OpenAI.mdx | 57 ++ pkg/integrations/agentcli/runner.go | 88 ++++ pkg/integrations/claude/claude.go | 1 + pkg/integrations/claude/run_claude_code.go | 491 ++++++++++++++++++ .../claude/run_claude_code_test.go | 202 +++++++ pkg/integrations/openai/openai.go | 1 + pkg/integrations/openai/run_codex_agent.go | 466 +++++++++++++++++ .../openai/run_codex_agent_test.go | 201 +++++++ .../pages/workflowv2/mappers/claude/index.ts | 2 + .../pages/workflowv2/mappers/openai/index.ts | 2 + 12 files changed, 1572 insertions(+) create mode 100644 pkg/integrations/agentcli/runner.go create mode 100644 pkg/integrations/claude/run_claude_code.go create mode 100644 pkg/integrations/claude/run_claude_code_test.go create mode 100644 pkg/integrations/openai/run_codex_agent.go create mode 100644 pkg/integrations/openai/run_codex_agent_test.go diff --git a/Dockerfile b/Dockerfile index 24277db439..306da6a1ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN apt-get update && \ RUN bash /opt/install-scripts/install-go.sh "${GO_VERSION}" RUN bash /opt/install-scripts/install-nodejs.sh +RUN npm install -g @openai/codex @anthropic-ai/claude-code && npm cache clean --force RUN bash /opt/install-scripts/install-postgresql-client.sh RUN bash /opt/install-scripts/install-gomigrate.sh RUN bash /opt/install-scripts/install-protoc.sh diff --git a/docs/components/Claude.mdx b/docs/components/Claude.mdx index 8107672943..9a9ca43264 100644 --- a/docs/components/Claude.mdx +++ b/docs/components/Claude.mdx @@ -10,6 +10,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -55,6 +56,65 @@ Emits a finished payload with **session** status, **session id**, and the final } ``` + + +## Run Claude Code + +**Component key:** `claude.runClaudeCode` + +The Run Claude Code component runs the Claude Code CLI in non-interactive print mode from the SuperPlane app container. + +### Use Cases + +- **Repository analysis**: Ask Claude Code to inspect code and summarize risks +- **Automated implementation tasks**: Run coding tasks from upstream workflow events +- **Code review**: Generate review feedback or remediation guidance +- **Local workflow automation**: Use SuperPlane events to start Claude Code tasks in a mounted workspace + +### Configuration + +- **Model**: Claude Code model alias or full model name. Defaults to `sonnet`. +- **Prompt**: The task sent to Claude Code (supports expressions). +- **Permission Mode**: Claude Code permission mode. Defaults to plan mode for read-only behavior. Write-capable and bypass modes should only be used in trusted local/dev environments. +- **Working Directory**: Directory used as the Claude Code process working directory. Defaults to `/app`. +- **Max Turns**: Maximum number of agentic turns in non-interactive mode. Defaults to 3. +- **Timeout**: Maximum runtime in seconds. Defaults to 600 seconds. + +### Output + +Routes to one of two channels: +- **success**: Claude Code exits with code 0 and does not report an error result +- **failed**: Claude Code exits non-zero, times out, or reports an error result + +The payload includes the final Claude Code result text, exit code, timeout flag, selected model, permission mode, working directory, max turns, duration, parsed JSON response when available, and stderr/stdout for failures. + +### Notes + +- Requires the Claude Code CLI on the app container PATH. +- Requires a valid Claude API key configured on the Claude integration. +- The API key is passed only to the Claude Code subprocess and is never emitted in the payload. +- The component uses Claude Code `--bare` and `--no-session-persistence` modes to reduce local side effects. + +### Example Output + +```json +{ + "data": { + "durationMs": 10180, + "exitCode": 0, + "isError": false, + "maxTurns": 3, + "model": "sonnet", + "permissionMode": "plan", + "text": "I inspected the requested files and drafted the implementation plan.", + "timedOut": false, + "workingDirectory": "/app" + }, + "timestamp": "2026-05-30T12:00:00Z", + "type": "claude.codeAgent.finished" +} +``` + ## Text Prompt diff --git a/docs/components/OpenAI.mdx b/docs/components/OpenAI.mdx index e689cc8d6b..b5a80c1483 100644 --- a/docs/components/OpenAI.mdx +++ b/docs/components/OpenAI.mdx @@ -9,9 +9,66 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions + + + +## Run Codex Agent + +**Component key:** `openai.runCodexAgent` + +The Run Codex Agent component runs the OpenAI Codex CLI in non-interactive mode from the SuperPlane app container. + +### Use Cases + +- **Repository analysis**: Ask Codex to inspect a codebase and summarize findings +- **Automated code review**: Run targeted review prompts against a mounted repository +- **Local coding tasks**: Let Codex propose or make workspace changes when write-capable sandbox modes are explicitly selected +- **Workflow automation**: Convert upstream events into coding-agent tasks + +### Configuration + +- **Model**: Codex model to use. Defaults to `gpt-5.1-codex-mini`. +- **Prompt**: The task sent to Codex (supports expressions). +- **Sandbox**: CLI sandbox mode. Defaults to read-only. Workspace write and full access modes should only be used in trusted local/dev environments. +- **Working Directory**: Directory passed to Codex as the workspace root. Defaults to `/app`. +- **Timeout**: Maximum runtime in seconds. Defaults to 600 seconds. + +### Output + +Routes to one of two channels: +- **success**: Codex exits with code 0 +- **failed**: Codex exits non-zero or times out + +The payload includes the final Codex message, exit code, timeout flag, selected model, sandbox mode, working directory, duration, parsed JSONL events when available, and stderr/stdout for failures. + +### Notes + +- Requires the Codex CLI on the app container PATH. +- Requires a valid OpenAI API key configured on the OpenAI integration. +- The API key is passed only to the Codex subprocess and is never emitted in the payload. +- The component runs locally in the SuperPlane app container, so sandbox modes should be chosen carefully. + +### Example Output + +```json +{ + "data": { + "durationMs": 12420, + "exitCode": 0, + "model": "gpt-5.1-codex-mini", + "sandbox": "read-only", + "text": "I reviewed the repository and found no obvious regression in the requested files.", + "timedOut": false, + "workingDirectory": "/app" + }, + "timestamp": "2026-05-30T12:00:00Z", + "type": "openai.codexAgent.finished" +} +``` + ## Text Prompt diff --git a/pkg/integrations/agentcli/runner.go b/pkg/integrations/agentcli/runner.go new file mode 100644 index 0000000000..a7cadc729a --- /dev/null +++ b/pkg/integrations/agentcli/runner.go @@ -0,0 +1,88 @@ +package agentcli + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" +) + +type Command struct { + Name string + Args []string + Dir string + Env map[string]string + Timeout time.Duration +} + +type Result struct { + Stdout string + Stderr string + ExitCode int + TimedOut bool + Duration time.Duration +} + +type Runner interface { + Run(ctx context.Context, command Command) (Result, error) +} + +type OSRunner struct{} + +func (r OSRunner) Run(ctx context.Context, command Command) (Result, error) { + if command.Name == "" { + return Result{}, fmt.Errorf("command name is required") + } + + if command.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, command.Timeout) + defer cancel() + } + + cmd := exec.CommandContext(ctx, command.Name, command.Args...) + cmd.Dir = command.Dir + cmd.Env = commandEnvironment(command.Env) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + startedAt := time.Now() + err := cmd.Run() + result := Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: time.Since(startedAt), + } + + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + result.TimedOut = true + result.ExitCode = -1 + return result, nil + } + + if err == nil { + return result, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + + return result, err +} + +func commandEnvironment(overrides map[string]string) []string { + env := os.Environ() + for key, value := range overrides { + env = append(env, key+"="+value) + } + return env +} diff --git a/pkg/integrations/claude/claude.go b/pkg/integrations/claude/claude.go index 05364a3d0c..24b91fe552 100644 --- a/pkg/integrations/claude/claude.go +++ b/pkg/integrations/claude/claude.go @@ -53,6 +53,7 @@ func (i *Claude) Actions() []core.Action { return []core.Action{ &TextPrompt{}, &runagent.RunAgent{}, + &RunClaudeCode{}, } } diff --git a/pkg/integrations/claude/run_claude_code.go b/pkg/integrations/claude/run_claude_code.go new file mode 100644 index 0000000000..f891c97618 --- /dev/null +++ b/pkg/integrations/claude/run_claude_code.go @@ -0,0 +1,491 @@ +package claude + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/agentcli" +) + +const ( + ClaudeCodePayloadType = "claude.codeAgent.finished" + + ClaudeCodeOutputChannelSuccess = "success" + ClaudeCodeOutputChannelFailed = "failed" + + defaultClaudeCodeModel = "sonnet" + defaultClaudeCodePermissionMode = "plan" + defaultClaudeCodeWorkingDirectory = "/app" + defaultClaudeCodeTimeoutSeconds = 600 + defaultClaudeCodeMaxTurns = 3 + maxClaudeCodeTimeoutSeconds = 86400 + maxClaudeCodeTurns = 100 +) + +var claudeCodePermissionModes = []string{"plan", "default", "acceptEdits", "auto", "dontAsk", "bypassPermissions"} + +type RunClaudeCode struct { + runner agentcli.Runner +} + +type RunClaudeCodeSpec struct { + Model string `json:"model" mapstructure:"model"` + Prompt string `json:"prompt" mapstructure:"prompt"` + PermissionMode string `json:"permissionMode" mapstructure:"permissionMode"` + WorkingDirectory string `json:"workingDirectory" mapstructure:"workingDirectory"` + TimeoutSeconds int `json:"timeoutSeconds" mapstructure:"timeoutSeconds"` + MaxTurns int `json:"maxTurns" mapstructure:"maxTurns"` +} + +type ClaudeCodePayload struct { + Text string `json:"text"` + ExitCode int `json:"exitCode"` + TimedOut bool `json:"timedOut"` + IsError bool `json:"isError"` + Model string `json:"model"` + PermissionMode string `json:"permissionMode"` + WorkingDirectory string `json:"workingDirectory"` + MaxTurns int `json:"maxTurns"` + DurationMs int64 `json:"durationMs"` + Response map[string]any `json:"response,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +func (c *RunClaudeCode) Name() string { + return "claude.runClaudeCode" +} + +func (c *RunClaudeCode) Label() string { + return "Run Claude Code" +} + +func (c *RunClaudeCode) Description() string { + return "Run Claude Code non-interactively on a local workspace" +} + +func (c *RunClaudeCode) Documentation() string { + return `The Run Claude Code component runs the Claude Code CLI in non-interactive print mode from the SuperPlane app container. + +## Use Cases + +- **Repository analysis**: Ask Claude Code to inspect code and summarize risks +- **Automated implementation tasks**: Run coding tasks from upstream workflow events +- **Code review**: Generate review feedback or remediation guidance +- **Local workflow automation**: Use SuperPlane events to start Claude Code tasks in a mounted workspace + +## Configuration + +- **Model**: Claude Code model alias or full model name. Defaults to ` + "`sonnet`" + `. +- **Prompt**: The task sent to Claude Code (supports expressions). +- **Permission Mode**: Claude Code permission mode. Defaults to plan mode for read-only behavior. Write-capable and bypass modes should only be used in trusted local/dev environments. +- **Working Directory**: Directory used as the Claude Code process working directory. Defaults to ` + "`/app`" + `. +- **Max Turns**: Maximum number of agentic turns in non-interactive mode. Defaults to 3. +- **Timeout**: Maximum runtime in seconds. Defaults to 600 seconds. + +## Output + +Routes to one of two channels: +- **success**: Claude Code exits with code 0 and does not report an error result +- **failed**: Claude Code exits non-zero, times out, or reports an error result + +The payload includes the final Claude Code result text, exit code, timeout flag, selected model, permission mode, working directory, max turns, duration, parsed JSON response when available, and stderr/stdout for failures. + +## Notes + +- Requires the Claude Code CLI on the app container PATH. +- Requires a valid Claude API key configured on the Claude integration. +- The API key is passed only to the Claude Code subprocess and is never emitted in the payload. +- The component uses Claude Code ` + "`--bare`" + ` and ` + "`--no-session-persistence`" + ` modes to reduce local side effects.` +} + +func (c *RunClaudeCode) Icon() string { + return "claude" +} + +func (c *RunClaudeCode) Color() string { + return "#C9784D" +} + +func (c *RunClaudeCode) ExampleOutput() map[string]any { + return map[string]any{ + "type": ClaudeCodePayloadType, + "timestamp": "2026-05-30T12:00:00Z", + "data": map[string]any{ + "text": "I inspected the requested files and drafted the implementation plan.", + "exitCode": 0, + "timedOut": false, + "isError": false, + "model": defaultClaudeCodeModel, + "permissionMode": defaultClaudeCodePermissionMode, + "workingDirectory": "/app", + "maxTurns": defaultClaudeCodeMaxTurns, + "durationMs": 10180, + }, + } +} + +func (c *RunClaudeCode) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{ + {Name: ClaudeCodeOutputChannelSuccess, Label: "Success"}, + {Name: ClaudeCodeOutputChannelFailed, Label: "Failed"}, + } +} + +func (c *RunClaudeCode) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "model", + Label: "Model", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Default: defaultClaudeCodeModel, + Placeholder: defaultClaudeCodeModel, + Description: "Claude model alias or full model name", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "model"}, + }, + }, + { + Name: "prompt", + Label: "Prompt", + Type: configuration.FieldTypeText, + Required: true, + Placeholder: "Review the deployment failure in {{ previous().data.summary }}", + Description: "Task sent to Claude Code. Supports expressions.", + }, + { + Name: "permissionMode", + Label: "Permission Mode", + Type: configuration.FieldTypeSelect, + Required: false, + Default: defaultClaudeCodePermissionMode, + Description: "Claude Code permission mode", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{Options: []configuration.FieldOption{ + {Label: "Plan", Value: "plan", Description: "Read-only planning mode."}, + {Label: "Default", Value: "default", Description: "Claude Code default permission behavior."}, + {Label: "Accept edits", Value: "acceptEdits", Description: "Allow Claude Code to apply edits."}, + {Label: "Auto", Value: "auto", Description: "Allow Claude Code to classify permissions automatically."}, + {Label: "Don't ask", Value: "dontAsk", Description: "Run without interactive permission prompts where supported."}, + {Label: "Bypass permissions", Value: "bypassPermissions", Description: "Dangerous: bypass permission checks for local/dev use only."}, + }}, + }, + }, + { + Name: "workingDirectory", + Label: "Working Directory", + Type: configuration.FieldTypeString, + Required: false, + Default: defaultClaudeCodeWorkingDirectory, + Placeholder: defaultClaudeCodeWorkingDirectory, + Description: "Directory used as the Claude Code process working directory", + }, + { + Name: "maxTurns", + Label: "Max Turns", + Type: configuration.FieldTypeNumber, + Required: false, + Default: defaultClaudeCodeMaxTurns, + Description: "Maximum number of agentic turns in non-interactive mode", + TypeOptions: &configuration.TypeOptions{ + Number: &configuration.NumberTypeOptions{ + Min: claudeCodeIntPtr(1), + Max: claudeCodeIntPtr(maxClaudeCodeTurns), + }, + }, + }, + { + Name: "timeoutSeconds", + Label: "Timeout (seconds)", + Type: configuration.FieldTypeNumber, + Required: false, + Default: defaultClaudeCodeTimeoutSeconds, + Description: "Maximum runtime in seconds", + TypeOptions: &configuration.TypeOptions{ + Number: &configuration.NumberTypeOptions{ + Min: claudeCodeIntPtr(1), + Max: claudeCodeIntPtr(maxClaudeCodeTimeoutSeconds), + }, + }, + }, + } +} + +func (c *RunClaudeCode) Setup(ctx core.SetupContext) error { + spec, err := decodeRunClaudeCodeSpec(ctx.Configuration) + if err != nil { + return err + } + return validateRunClaudeCodeSpec(spec, true) +} + +func (c *RunClaudeCode) Execute(ctx core.ExecutionContext) error { + spec, err := decodeRunClaudeCodeSpec(ctx.Configuration) + if err != nil { + return err + } + if err := validateRunClaudeCodeSpec(spec, true); err != nil { + return err + } + + apiKey, err := ctx.Integration.GetConfig("apiKey") + if err != nil { + return fmt.Errorf("failed to read Claude API key: %w", err) + } + if len(apiKey) == 0 { + return fmt.Errorf("apiKey is required") + } + + command := buildClaudeCodeCommand(spec, string(apiKey)) + result, err := c.commandRunner().Run(context.Background(), command) + if err != nil { + return fmt.Errorf("failed to run Claude Code CLI: %w", err) + } + + response := parseClaudeCodeResponse(result.Stdout) + text := extractClaudeCodeText(response) + isError := claudeCodeResponseIsError(response) + + payload := ClaudeCodePayload{ + Text: text, + ExitCode: result.ExitCode, + TimedOut: result.TimedOut, + IsError: isError, + Model: spec.Model, + PermissionMode: spec.PermissionMode, + WorkingDirectory: spec.WorkingDirectory, + MaxTurns: spec.MaxTurns, + DurationMs: result.Duration.Milliseconds(), + Response: response, + } + + channel := ClaudeCodeOutputChannelSuccess + if result.ExitCode != 0 || result.TimedOut || isError { + channel = ClaudeCodeOutputChannelFailed + payload.Stdout = result.Stdout + payload.Stderr = result.Stderr + } + + return ctx.ExecutionState.Emit(channel, ClaudeCodePayloadType, []any{payload}) +} + +func (c *RunClaudeCode) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *RunClaudeCode) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *RunClaudeCode) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *RunClaudeCode) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *RunClaudeCode) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *RunClaudeCode) HandleHook(ctx core.ActionHookContext) error { + return nil +} + +func (c *RunClaudeCode) commandRunner() agentcli.Runner { + if c.runner != nil { + return c.runner + } + return agentcli.OSRunner{} +} + +func decodeRunClaudeCodeSpec(raw any) (RunClaudeCodeSpec, error) { + var spec RunClaudeCodeSpec + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &spec, + WeaklyTypedInput: true, + TagName: "mapstructure", + }) + if err != nil { + return RunClaudeCodeSpec{}, fmt.Errorf("claude code spec decoder: %w", err) + } + if err := decoder.Decode(raw); err != nil { + return RunClaudeCodeSpec{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.Model = strings.TrimSpace(spec.Model) + if spec.Model == "" { + spec.Model = defaultClaudeCodeModel + } + spec.Prompt = strings.TrimSpace(spec.Prompt) + spec.PermissionMode = strings.TrimSpace(spec.PermissionMode) + if spec.PermissionMode == "" { + spec.PermissionMode = defaultClaudeCodePermissionMode + } + spec.WorkingDirectory = strings.TrimSpace(spec.WorkingDirectory) + if spec.WorkingDirectory == "" { + spec.WorkingDirectory = defaultClaudeCodeWorkingDirectory + } + if spec.TimeoutSeconds <= 0 { + spec.TimeoutSeconds = defaultClaudeCodeTimeoutSeconds + } + if spec.MaxTurns <= 0 { + spec.MaxTurns = defaultClaudeCodeMaxTurns + } + + return spec, nil +} + +func validateRunClaudeCodeSpec(spec RunClaudeCodeSpec, checkWorkingDirectory bool) error { + if spec.Prompt == "" { + return fmt.Errorf("prompt is required") + } + if !slices.Contains(claudeCodePermissionModes, spec.PermissionMode) { + return fmt.Errorf("permissionMode must be one of: %s", strings.Join(claudeCodePermissionModes, ", ")) + } + if spec.MaxTurns < 1 || spec.MaxTurns > maxClaudeCodeTurns { + return fmt.Errorf("maxTurns must be between 1 and %d", maxClaudeCodeTurns) + } + if spec.TimeoutSeconds < 1 || spec.TimeoutSeconds > maxClaudeCodeTimeoutSeconds { + return fmt.Errorf("timeoutSeconds must be between 1 and %d", maxClaudeCodeTimeoutSeconds) + } + if checkWorkingDirectory { + if err := validateClaudeCodeDirectory(spec.WorkingDirectory); err != nil { + return fmt.Errorf("workingDirectory: %w", err) + } + } + return nil +} + +func buildClaudeCodeCommand(spec RunClaudeCodeSpec, apiKey string) agentcli.Command { + args := []string{ + "--bare", + "-p", + "--output-format", + "json", + "--no-session-persistence", + "--permission-mode", + spec.PermissionMode, + "--model", + spec.Model, + "--max-turns", + strconv.Itoa(spec.MaxTurns), + spec.Prompt, + } + + return agentcli.Command{ + Name: "claude", + Args: args, + Dir: spec.WorkingDirectory, + Timeout: time.Duration(spec.TimeoutSeconds) * time.Second, + Env: map[string]string{ + "ANTHROPIC_API_KEY": apiKey, + "DISABLE_AUTOUPDATER": "1", + }, + } +} + +func parseClaudeCodeResponse(stdout string) map[string]any { + trimmed := strings.TrimSpace(stdout) + if trimmed == "" { + return nil + } + + var response map[string]any + if err := json.Unmarshal([]byte(trimmed), &response); err == nil { + return response + } + + lines := strings.Split(trimmed, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if err := json.Unmarshal([]byte(line), &response); err == nil { + return response + } + } + return nil +} + +func extractClaudeCodeText(response map[string]any) string { + if response == nil { + return "" + } + for _, key := range []string{"result", "text", "message", "content", "output"} { + if text := extractClaudeCodeTextValue(response[key]); text != "" { + return text + } + } + return "" +} + +func extractClaudeCodeTextValue(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case []any: + parts := []string{} + for _, item := range v { + if text := extractClaudeCodeTextValue(item); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") + case map[string]any: + for _, key := range []string{"text", "result", "message", "content", "output"} { + if text := extractClaudeCodeTextValue(v[key]); text != "" { + return text + } + } + } + return "" +} + +func claudeCodeResponseIsError(response map[string]any) bool { + if response == nil { + return false + } + if isError, ok := response["is_error"].(bool); ok { + return isError + } + if subtype, ok := response["subtype"].(string); ok && strings.Contains(strings.ToLower(subtype), "error") { + return true + } + if typ, ok := response["type"].(string); ok && strings.Contains(strings.ToLower(typ), "error") { + return true + } + return false +} + +func validateClaudeCodeDirectory(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("is required") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + return nil +} + +func claudeCodeIntPtr(v int) *int { + return &v +} diff --git a/pkg/integrations/claude/run_claude_code_test.go b/pkg/integrations/claude/run_claude_code_test.go new file mode 100644 index 0000000000..d0a856d269 --- /dev/null +++ b/pkg/integrations/claude/run_claude_code_test.go @@ -0,0 +1,202 @@ +package claude + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/agentcli" + "github.com/superplanehq/superplane/test/support/contexts" +) + +type fakeClaudeCodeRunner struct { + command agentcli.Command + result agentcli.Result + err error +} + +func (r *fakeClaudeCodeRunner) Run(ctx context.Context, command agentcli.Command) (agentcli.Result, error) { + r.command = command + return r.result, r.err +} + +func TestRunClaudeCode_Setup(t *testing.T) { + workingDirectory := t.TempDir() + + tests := []struct { + name string + configuration map[string]any + expectedError string + }{ + { + name: "valid", + configuration: map[string]any{ + "prompt": "Review the repository", + "workingDirectory": workingDirectory, + }, + }, + { + name: "missing prompt", + configuration: map[string]any{ + "workingDirectory": workingDirectory, + }, + expectedError: "prompt is required", + }, + { + name: "invalid permission mode", + configuration: map[string]any{ + "prompt": "Review", + "permissionMode": "invalid", + "workingDirectory": workingDirectory, + }, + expectedError: "permissionMode must be one of", + }, + { + name: "invalid max turns", + configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + "maxTurns": maxClaudeCodeTurns + 1, + }, + expectedError: "maxTurns must be between", + }, + { + name: "missing working directory", + configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory + "/missing", + }, + expectedError: "workingDirectory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + component := &RunClaudeCode{} + err := component.Setup(core.SetupContext{Configuration: tt.configuration}) + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + return + } + require.NoError(t, err) + }) + } +} + +func TestRunClaudeCode_ExecuteSuccess(t *testing.T) { + workingDirectory := t.TempDir() + runner := &fakeClaudeCodeRunner{ + result: agentcli.Result{ + Stdout: `{"type":"result","subtype":"success","is_error":false,"result":"Finished implementation."}`, + ExitCode: 0, + Duration: 2 * time.Second, + }, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + component := &RunClaudeCode{runner: runner} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "model": "sonnet", + "prompt": "Implement the feature", + "permissionMode": "plan", + "workingDirectory": workingDirectory, + "timeoutSeconds": 45, + "maxTurns": 4, + }, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-ant-test"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, "claude", runner.command.Name) + assert.Equal(t, workingDirectory, runner.command.Dir) + assert.Equal(t, 45*time.Second, runner.command.Timeout) + assert.Equal(t, "sk-ant-test", runner.command.Env["ANTHROPIC_API_KEY"]) + assert.Equal(t, "1", runner.command.Env["DISABLE_AUTOUPDATER"]) + assert.Contains(t, runner.command.Args, "--bare") + assert.Contains(t, runner.command.Args, "--no-session-persistence") + assert.Contains(t, runner.command.Args, "--permission-mode") + assert.Contains(t, runner.command.Args, "plan") + assert.Contains(t, runner.command.Args, "--max-turns") + assert.Contains(t, runner.command.Args, "4") + assert.Contains(t, runner.command.Args, "Implement the feature") + + assert.Equal(t, ClaudeCodeOutputChannelSuccess, execState.Channel) + assert.Equal(t, ClaudeCodePayloadType, execState.Type) + require.Len(t, execState.Payloads, 1) + + wrapped := execState.Payloads[0].(map[string]any) + payload := wrapped["data"].(ClaudeCodePayload) + assert.Equal(t, "Finished implementation.", payload.Text) + assert.Equal(t, 0, payload.ExitCode) + assert.False(t, payload.TimedOut) + assert.False(t, payload.IsError) + assert.Equal(t, int64(2000), payload.DurationMs) + assert.Equal(t, "success", payload.Response["subtype"]) +} + +func TestRunClaudeCode_ExecuteRoutesErrorResponseToFailed(t *testing.T) { + workingDirectory := t.TempDir() + runner := &fakeClaudeCodeRunner{ + result: agentcli.Result{ + Stdout: `{"type":"result","subtype":"error_max_turns","is_error":true,"result":"Reached max turns"}`, + ExitCode: 0, + }, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + component := &RunClaudeCode{runner: runner} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + }, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-ant-test"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, ClaudeCodeOutputChannelFailed, execState.Channel) + wrapped := execState.Payloads[0].(map[string]any) + payload := wrapped["data"].(ClaudeCodePayload) + assert.True(t, payload.IsError) + assert.Equal(t, "Reached max turns", payload.Text) +} + +func TestRunClaudeCode_ExecuteRunnerError(t *testing.T) { + workingDirectory := t.TempDir() + component := &RunClaudeCode{runner: &fakeClaudeCodeRunner{err: errors.New("not found")}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + }, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-ant-test"}, + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to run Claude Code CLI") +} + +func TestClaude_ActionsIncludesRunClaudeCode(t *testing.T) { + actions := (&Claude{}).Actions() + found := slices.ContainsFunc(actions, func(action core.Action) bool { + return action.Name() == "claude.runClaudeCode" + }) + assert.True(t, found) +} diff --git a/pkg/integrations/openai/openai.go b/pkg/integrations/openai/openai.go index b68840a76f..1bacf0eec4 100644 --- a/pkg/integrations/openai/openai.go +++ b/pkg/integrations/openai/openai.go @@ -60,6 +60,7 @@ func (o *OpenAI) Configuration() []configuration.Field { func (o *OpenAI) Actions() []core.Action { return []core.Action{ &CreateResponse{}, + &RunCodexAgent{}, } } diff --git a/pkg/integrations/openai/run_codex_agent.go b/pkg/integrations/openai/run_codex_agent.go new file mode 100644 index 0000000000..09be1d68dc --- /dev/null +++ b/pkg/integrations/openai/run_codex_agent.go @@ -0,0 +1,466 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/agentcli" +) + +const ( + CodexAgentPayloadType = "openai.codexAgent.finished" + + CodexAgentOutputChannelSuccess = "success" + CodexAgentOutputChannelFailed = "failed" + + defaultCodexModel = "gpt-5.1-codex-mini" + defaultCodexSandbox = "read-only" + defaultCodexWorkingDirectory = "/app" + defaultCodexTimeoutSeconds = 600 + maxCodexTimeoutSeconds = 86400 +) + +var codexSandboxOptions = []string{"read-only", "workspace-write", "danger-full-access"} + +type RunCodexAgent struct { + runner agentcli.Runner +} + +type RunCodexAgentSpec struct { + Model string `json:"model" mapstructure:"model"` + Prompt string `json:"prompt" mapstructure:"prompt"` + Sandbox string `json:"sandbox" mapstructure:"sandbox"` + WorkingDirectory string `json:"workingDirectory" mapstructure:"workingDirectory"` + TimeoutSeconds int `json:"timeoutSeconds" mapstructure:"timeoutSeconds"` +} + +type CodexAgentPayload struct { + Text string `json:"text"` + ExitCode int `json:"exitCode"` + TimedOut bool `json:"timedOut"` + Model string `json:"model"` + Sandbox string `json:"sandbox"` + WorkingDirectory string `json:"workingDirectory"` + DurationMs int64 `json:"durationMs"` + Events []map[string]any `json:"events,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +func (a *RunCodexAgent) Name() string { + return "openai.runCodexAgent" +} + +func (a *RunCodexAgent) Label() string { + return "Run Codex Agent" +} + +func (a *RunCodexAgent) Description() string { + return "Run OpenAI Codex CLI non-interactively on a local workspace" +} + +func (a *RunCodexAgent) Documentation() string { + return `The Run Codex Agent component runs the OpenAI Codex CLI in non-interactive mode from the SuperPlane app container. + +## Use Cases + +- **Repository analysis**: Ask Codex to inspect a codebase and summarize findings +- **Automated code review**: Run targeted review prompts against a mounted repository +- **Local coding tasks**: Let Codex propose or make workspace changes when write-capable sandbox modes are explicitly selected +- **Workflow automation**: Convert upstream events into coding-agent tasks + +## Configuration + +- **Model**: Codex model to use. Defaults to ` + "`gpt-5.1-codex-mini`" + `. +- **Prompt**: The task sent to Codex (supports expressions). +- **Sandbox**: CLI sandbox mode. Defaults to read-only. Workspace write and full access modes should only be used in trusted local/dev environments. +- **Working Directory**: Directory passed to Codex as the workspace root. Defaults to ` + "`/app`" + `. +- **Timeout**: Maximum runtime in seconds. Defaults to 600 seconds. + +## Output + +Routes to one of two channels: +- **success**: Codex exits with code 0 +- **failed**: Codex exits non-zero or times out + +The payload includes the final Codex message, exit code, timeout flag, selected model, sandbox mode, working directory, duration, parsed JSONL events when available, and stderr/stdout for failures. + +## Notes + +- Requires the Codex CLI on the app container PATH. +- Requires a valid OpenAI API key configured on the OpenAI integration. +- The API key is passed only to the Codex subprocess and is never emitted in the payload. +- The component runs locally in the SuperPlane app container, so sandbox modes should be chosen carefully.` +} + +func (a *RunCodexAgent) Icon() string { + return "openai" +} + +func (a *RunCodexAgent) Color() string { + return "gray" +} + +func (a *RunCodexAgent) ExampleOutput() map[string]any { + return map[string]any{ + "type": CodexAgentPayloadType, + "timestamp": "2026-05-30T12:00:00Z", + "data": map[string]any{ + "text": "I reviewed the repository and found no obvious regression in the requested files.", + "exitCode": 0, + "timedOut": false, + "model": defaultCodexModel, + "sandbox": defaultCodexSandbox, + "workingDirectory": "/app", + "durationMs": 12420, + }, + } +} + +func (a *RunCodexAgent) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{ + {Name: CodexAgentOutputChannelSuccess, Label: "Success"}, + {Name: CodexAgentOutputChannelFailed, Label: "Failed"}, + } +} + +func (a *RunCodexAgent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "model", + Label: "Model", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Default: defaultCodexModel, + Placeholder: defaultCodexModel, + Description: "Codex model to use", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "model"}, + }, + }, + { + Name: "prompt", + Label: "Prompt", + Type: configuration.FieldTypeText, + Required: true, + Placeholder: "Review this repository for the issue described in {{ previous().data.title }}", + Description: "Task sent to Codex. Supports expressions.", + }, + { + Name: "sandbox", + Label: "Sandbox", + Type: configuration.FieldTypeSelect, + Required: false, + Default: defaultCodexSandbox, + Description: "Sandbox mode for model-generated shell commands", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{Options: []configuration.FieldOption{ + {Label: "Read-only", Value: "read-only", Description: "Codex can inspect files but cannot write to the workspace."}, + {Label: "Workspace write", Value: "workspace-write", Description: "Codex can write inside the workspace."}, + {Label: "Full access", Value: "danger-full-access", Description: "Dangerous: disables sandbox restrictions for local/dev use only."}, + }}, + }, + }, + { + Name: "workingDirectory", + Label: "Working Directory", + Type: configuration.FieldTypeString, + Required: false, + Default: defaultCodexWorkingDirectory, + Placeholder: defaultCodexWorkingDirectory, + Description: "Directory used as the Codex workspace root", + }, + { + Name: "timeoutSeconds", + Label: "Timeout (seconds)", + Type: configuration.FieldTypeNumber, + Required: false, + Default: defaultCodexTimeoutSeconds, + Description: "Maximum runtime in seconds", + TypeOptions: &configuration.TypeOptions{ + Number: &configuration.NumberTypeOptions{ + Min: intPtr(1), + Max: intPtr(maxCodexTimeoutSeconds), + }, + }, + }, + } +} + +func (a *RunCodexAgent) Setup(ctx core.SetupContext) error { + spec, err := decodeRunCodexAgentSpec(ctx.Configuration) + if err != nil { + return err + } + return validateRunCodexAgentSpec(spec, true) +} + +func (a *RunCodexAgent) Execute(ctx core.ExecutionContext) error { + spec, err := decodeRunCodexAgentSpec(ctx.Configuration) + if err != nil { + return err + } + if err := validateRunCodexAgentSpec(spec, true); err != nil { + return err + } + + apiKey, err := ctx.Integration.GetConfig("apiKey") + if err != nil { + return fmt.Errorf("failed to read OpenAI API key: %w", err) + } + if len(apiKey) == 0 { + return fmt.Errorf("apiKey is required") + } + + lastMessageFile, cleanup, err := createLastMessageFile() + if err != nil { + return err + } + defer cleanup() + + command := buildCodexAgentCommand(spec, string(apiKey), lastMessageFile) + result, err := a.commandRunner().Run(context.Background(), command) + if err != nil { + return fmt.Errorf("failed to run Codex CLI: %w", err) + } + + events := parseCodexJSONLEvents(result.Stdout) + text := readCodexLastMessage(lastMessageFile) + if text == "" { + text = extractCodexText(events) + } + + payload := CodexAgentPayload{ + Text: text, + ExitCode: result.ExitCode, + TimedOut: result.TimedOut, + Model: spec.Model, + Sandbox: spec.Sandbox, + WorkingDirectory: spec.WorkingDirectory, + DurationMs: result.Duration.Milliseconds(), + Events: events, + } + + channel := CodexAgentOutputChannelSuccess + if result.ExitCode != 0 || result.TimedOut { + channel = CodexAgentOutputChannelFailed + payload.Stdout = result.Stdout + payload.Stderr = result.Stderr + } + + return ctx.ExecutionState.Emit(channel, CodexAgentPayloadType, []any{payload}) +} + +func (a *RunCodexAgent) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (a *RunCodexAgent) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (a *RunCodexAgent) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (a *RunCodexAgent) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (a *RunCodexAgent) Hooks() []core.Hook { + return []core.Hook{} +} + +func (a *RunCodexAgent) HandleHook(ctx core.ActionHookContext) error { + return nil +} + +func (a *RunCodexAgent) commandRunner() agentcli.Runner { + if a.runner != nil { + return a.runner + } + return agentcli.OSRunner{} +} + +func decodeRunCodexAgentSpec(raw any) (RunCodexAgentSpec, error) { + var spec RunCodexAgentSpec + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &spec, + WeaklyTypedInput: true, + TagName: "mapstructure", + }) + if err != nil { + return RunCodexAgentSpec{}, fmt.Errorf("codex agent spec decoder: %w", err) + } + if err := decoder.Decode(raw); err != nil { + return RunCodexAgentSpec{}, fmt.Errorf("failed to decode configuration: %w", err) + } + + spec.Model = strings.TrimSpace(spec.Model) + if spec.Model == "" { + spec.Model = defaultCodexModel + } + spec.Prompt = strings.TrimSpace(spec.Prompt) + spec.Sandbox = strings.TrimSpace(spec.Sandbox) + if spec.Sandbox == "" { + spec.Sandbox = defaultCodexSandbox + } + spec.WorkingDirectory = strings.TrimSpace(spec.WorkingDirectory) + if spec.WorkingDirectory == "" { + spec.WorkingDirectory = defaultCodexWorkingDirectory + } + if spec.TimeoutSeconds <= 0 { + spec.TimeoutSeconds = defaultCodexTimeoutSeconds + } + + return spec, nil +} + +func validateRunCodexAgentSpec(spec RunCodexAgentSpec, checkWorkingDirectory bool) error { + if spec.Prompt == "" { + return fmt.Errorf("prompt is required") + } + if !slices.Contains(codexSandboxOptions, spec.Sandbox) { + return fmt.Errorf("sandbox must be one of: %s", strings.Join(codexSandboxOptions, ", ")) + } + if spec.TimeoutSeconds < 1 || spec.TimeoutSeconds > maxCodexTimeoutSeconds { + return fmt.Errorf("timeoutSeconds must be between 1 and %d", maxCodexTimeoutSeconds) + } + if checkWorkingDirectory { + if err := validateDirectory(spec.WorkingDirectory); err != nil { + return fmt.Errorf("workingDirectory: %w", err) + } + } + return nil +} + +func buildCodexAgentCommand(spec RunCodexAgentSpec, apiKey string, lastMessageFile string) agentcli.Command { + args := []string{ + "exec", + "--json", + "--ephemeral", + "--skip-git-repo-check", + "--sandbox", + spec.Sandbox, + "--cd", + spec.WorkingDirectory, + "--model", + spec.Model, + "--output-last-message", + lastMessageFile, + spec.Prompt, + } + + return agentcli.Command{ + Name: "codex", + Args: args, + Dir: spec.WorkingDirectory, + Timeout: time.Duration(spec.TimeoutSeconds) * time.Second, + Env: map[string]string{ + "CODEX_API_KEY": apiKey, + "OPENAI_API_KEY": apiKey, + }, + } +} + +func createLastMessageFile() (string, func(), error) { + file, err := os.CreateTemp("", "superplane-codex-last-message-*.txt") + if err != nil { + return "", nil, fmt.Errorf("failed to create Codex output file: %w", err) + } + path := file.Name() + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", nil, fmt.Errorf("failed to close Codex output file: %w", err) + } + + return path, func() { _ = os.Remove(path) }, nil +} + +func readCodexLastMessage(path string) string { + if path == "" { + return "" + } + content, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +func parseCodexJSONLEvents(stdout string) []map[string]any { + lines := strings.Split(stdout, "\n") + events := make([]map[string]any, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var event map[string]any + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue + } + events = append(events, event) + } + return events +} + +func extractCodexText(events []map[string]any) string { + for i := len(events) - 1; i >= 0; i-- { + if text := extractTextValue(events[i]); text != "" { + return text + } + } + return "" +} + +func extractTextValue(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case []any: + parts := []string{} + for _, item := range v { + if text := extractTextValue(item); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") + case map[string]any: + for _, key := range []string{"text", "message", "content", "output", "result", "final_output", "last_message"} { + if text := extractTextValue(v[key]); text != "" { + return text + } + } + } + return "" +} + +func validateDirectory(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("is required") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + return nil +} + +func intPtr(v int) *int { + return &v +} diff --git a/pkg/integrations/openai/run_codex_agent_test.go b/pkg/integrations/openai/run_codex_agent_test.go new file mode 100644 index 0000000000..ae4d758ddc --- /dev/null +++ b/pkg/integrations/openai/run_codex_agent_test.go @@ -0,0 +1,201 @@ +package openai + +import ( + "context" + "errors" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/integrations/agentcli" + "github.com/superplanehq/superplane/test/support/contexts" +) + +type fakeCodexRunner struct { + command agentcli.Command + result agentcli.Result + err error +} + +func (r *fakeCodexRunner) Run(ctx context.Context, command agentcli.Command) (agentcli.Result, error) { + r.command = command + return r.result, r.err +} + +func TestRunCodexAgent_Setup(t *testing.T) { + workingDirectory := t.TempDir() + + tests := []struct { + name string + configuration map[string]any + expectedError string + }{ + { + name: "valid", + configuration: map[string]any{ + "prompt": "Review the repository", + "workingDirectory": workingDirectory, + }, + }, + { + name: "missing prompt", + configuration: map[string]any{ + "workingDirectory": workingDirectory, + }, + expectedError: "prompt is required", + }, + { + name: "invalid sandbox", + configuration: map[string]any{ + "prompt": "Review", + "sandbox": "invalid", + "workingDirectory": workingDirectory, + }, + expectedError: "sandbox must be one of", + }, + { + name: "invalid timeout", + configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + "timeoutSeconds": maxCodexTimeoutSeconds + 1, + }, + expectedError: "timeoutSeconds must be between", + }, + { + name: "missing working directory", + configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory + "/missing", + }, + expectedError: "workingDirectory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + component := &RunCodexAgent{} + err := component.Setup(core.SetupContext{Configuration: tt.configuration}) + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + return + } + require.NoError(t, err) + }) + } +} + +func TestRunCodexAgent_ExecuteSuccess(t *testing.T) { + workingDirectory := t.TempDir() + runner := &fakeCodexRunner{ + result: agentcli.Result{ + Stdout: `{"type":"agent_message","message":"Finished review."}` + "\n", + ExitCode: 0, + Duration: 1500 * time.Millisecond, + }, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + component := &RunCodexAgent{runner: runner} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "model": "gpt-5.1-codex-mini", + "prompt": "Review the repository", + "sandbox": "read-only", + "workingDirectory": workingDirectory, + "timeoutSeconds": 30, + }, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-test"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, "codex", runner.command.Name) + assert.Equal(t, workingDirectory, runner.command.Dir) + assert.Equal(t, 30*time.Second, runner.command.Timeout) + assert.Equal(t, "sk-test", runner.command.Env["CODEX_API_KEY"]) + assert.Equal(t, "sk-test", runner.command.Env["OPENAI_API_KEY"]) + assert.Contains(t, runner.command.Args, "--json") + assert.Contains(t, runner.command.Args, "--ephemeral") + assert.Contains(t, runner.command.Args, "--output-last-message") + assert.Contains(t, runner.command.Args, "Review the repository") + + assert.Equal(t, CodexAgentOutputChannelSuccess, execState.Channel) + assert.Equal(t, CodexAgentPayloadType, execState.Type) + require.Len(t, execState.Payloads, 1) + + wrapped := execState.Payloads[0].(map[string]any) + payload := wrapped["data"].(CodexAgentPayload) + assert.Equal(t, "Finished review.", payload.Text) + assert.Equal(t, 0, payload.ExitCode) + assert.False(t, payload.TimedOut) + assert.Equal(t, int64(1500), payload.DurationMs) + assert.Empty(t, payload.Stderr) + require.Len(t, payload.Events, 1) + assert.Equal(t, "agent_message", payload.Events[0]["type"]) +} + +func TestRunCodexAgent_ExecuteRoutesNonZeroExitToFailed(t *testing.T) { + workingDirectory := t.TempDir() + runner := &fakeCodexRunner{ + result: agentcli.Result{ + Stdout: "not json", + Stderr: "Codex failed", + ExitCode: 2, + }, + } + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + component := &RunCodexAgent{runner: runner} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + }, + ExecutionState: execState, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-test"}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, CodexAgentOutputChannelFailed, execState.Channel) + wrapped := execState.Payloads[0].(map[string]any) + payload := wrapped["data"].(CodexAgentPayload) + assert.Equal(t, 2, payload.ExitCode) + assert.Equal(t, "not json", payload.Stdout) + assert.Equal(t, "Codex failed", payload.Stderr) +} + +func TestRunCodexAgent_ExecuteRunnerError(t *testing.T) { + workingDirectory := t.TempDir() + component := &RunCodexAgent{runner: &fakeCodexRunner{err: errors.New("not found")}} + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "prompt": "Review", + "workingDirectory": workingDirectory, + }, + ExecutionState: &contexts.ExecutionStateContext{KVs: map[string]string{}}, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{"apiKey": "sk-test"}, + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to run Codex CLI") +} + +func TestOpenAI_ActionsIncludesRunCodexAgent(t *testing.T) { + actions := (&OpenAI{}).Actions() + found := slices.ContainsFunc(actions, func(action core.Action) bool { + return action.Name() == "openai.runCodexAgent" + }) + assert.True(t, found) +} diff --git a/web_src/src/pages/workflowv2/mappers/claude/index.ts b/web_src/src/pages/workflowv2/mappers/claude/index.ts index 076fef8b10..ceb99a61e0 100644 --- a/web_src/src/pages/workflowv2/mappers/claude/index.ts +++ b/web_src/src/pages/workflowv2/mappers/claude/index.ts @@ -5,6 +5,7 @@ import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { textPrompt: baseMapper, runAgent: baseMapper, + runClaudeCode: baseMapper, }; export const triggerRenderers: Record = {}; @@ -12,4 +13,5 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { textPrompt: buildActionStateRegistry("completed"), runAgent: buildActionStateRegistry("completed"), + runClaudeCode: buildActionStateRegistry("completed"), }; diff --git a/web_src/src/pages/workflowv2/mappers/openai/index.ts b/web_src/src/pages/workflowv2/mappers/openai/index.ts index 0f4c320c6b..34088a5aed 100644 --- a/web_src/src/pages/workflowv2/mappers/openai/index.ts +++ b/web_src/src/pages/workflowv2/mappers/openai/index.ts @@ -3,11 +3,13 @@ import { baseMapper } from "./base"; import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { + runCodexAgent: baseMapper, textPrompt: baseMapper, }; export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { + runCodexAgent: buildActionStateRegistry("completed"), textPrompt: buildActionStateRegistry("completed"), };