From f43244ec3ebf4378711a57753d997157fd34dcbc Mon Sep 17 00:00:00 2001 From: DanielLi Date: Fri, 27 Feb 2026 00:46:24 +0800 Subject: [PATCH] feat(codeagent-wrapper): add --output structured JSON file (#151) * feat(codeagent-wrapper): add --output structured JSON file * fix(codeagent-wrapper): write --output on failure --------- Co-authored-by: danielee.eth Co-authored-by: cexll --- codeagent-wrapper/internal/app/cli.go | 50 ++- codeagent-wrapper/internal/app/main_test.go | 293 ++++++++++++++++++ codeagent-wrapper/internal/app/output_file.go | 65 ++++ codeagent-wrapper/internal/config/config.go | 1 + 4 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 codeagent-wrapper/internal/app/output_file.go diff --git a/codeagent-wrapper/internal/app/cli.go b/codeagent-wrapper/internal/app/cli.go index bb64d66..a0da755 100644 --- a/codeagent-wrapper/internal/app/cli.go +++ b/codeagent-wrapper/internal/app/cli.go @@ -29,6 +29,7 @@ type cliOptions struct { ReasoningEffort string Agent string PromptFile string + Output string Skills string SkipPermissions bool Worktree bool @@ -135,6 +136,7 @@ func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) { fs.StringVar(&opts.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)") fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)") fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path") + fs.StringVar(&opts.Output, "output", "", "Write structured JSON output to file") fs.StringVar(&opts.Skills, "skills", "", "Comma-separated skill names for spec injection") fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)") @@ -237,6 +239,7 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts agentName := "" promptFile := "" promptFileExplicit := false + outputPath := "" yolo := false if cmd.Flags().Changed("agent") { @@ -281,6 +284,15 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts promptFile = resolvedPromptFile } + if cmd.Flags().Changed("output") { + outputPath = strings.TrimSpace(opts.Output) + if outputPath == "" { + return nil, fmt.Errorf("--output flag requires a value") + } + } else if val := strings.TrimSpace(v.GetString("output")); val != "" { + outputPath = val + } + agentFlagChanged := cmd.Flags().Changed("agent") backendFlagChanged := cmd.Flags().Changed("backend") if backendFlagChanged { @@ -357,6 +369,7 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts Agent: agentName, PromptFile: promptFile, PromptFileExplicit: promptFileExplicit, + OutputPath: outputPath, SkipPermissions: skipPermissions, Yolo: yolo, Model: model, @@ -432,7 +445,7 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip } if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") || cmd.Flags().Changed("skills") { - fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.") + fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --output, --full-output and --skip-permissions are allowed.") return 1 } @@ -463,6 +476,17 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip fullOutput = v.GetBool("full-output") } + outputPath := "" + if cmd.Flags().Changed("output") { + outputPath = strings.TrimSpace(opts.Output) + if outputPath == "" { + fmt.Fprintln(os.Stderr, "ERROR: --output flag requires a value") + return 1 + } + } else if val := strings.TrimSpace(v.GetString("output")); val != "" { + outputPath = val + } + skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions") skipPermissions := false if skipChanged { @@ -525,6 +549,11 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip results[i].KeyOutput = extractKeyOutputFromLines(lines, 150) } + if err := writeStructuredOutput(outputPath, results); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + return 1 + } + fmt.Println(generateFinalOutputWithMode(results, !fullOutput)) exitCode := 0 @@ -688,16 +717,25 @@ func runSingleMode(cfg *Config, name string) int { result := runTaskFn(taskSpec, false, cfg.Timeout) - if result.ExitCode != 0 { - return result.ExitCode + exitCode := result.ExitCode + if exitCode == 0 && strings.TrimSpace(result.Message) == "" { + errMsg := fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend) + logError(errMsg) + exitCode = 1 + if strings.TrimSpace(result.Error) == "" { + result.Error = errMsg + } } - // Validate that we got a meaningful output message - if strings.TrimSpace(result.Message) == "" { - logError(fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend)) + if err := writeStructuredOutput(cfg.OutputPath, []TaskResult{result}); err != nil { + logError(err.Error()) return 1 } + if exitCode != 0 { + return exitCode + } + fmt.Println(result.Message) if result.SessionID != "" { fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID) diff --git a/codeagent-wrapper/internal/app/main_test.go b/codeagent-wrapper/internal/app/main_test.go index 3a58bdb..e4ff335 100644 --- a/codeagent-wrapper/internal/app/main_test.go +++ b/codeagent-wrapper/internal/app/main_test.go @@ -1455,6 +1455,60 @@ func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) { } } +func TestBackendParseArgs_OutputFlag(t *testing.T) { + tests := []struct { + name string + args []string + want string + wantErr bool + }{ + { + name: "output flag", + args: []string{"codeagent-wrapper", "--output", "/tmp/out.json", "task"}, + want: "/tmp/out.json", + }, + { + name: "output equals syntax", + args: []string{"codeagent-wrapper", "--output=/tmp/out.json", "task"}, + want: "/tmp/out.json", + }, + { + name: "output trimmed", + args: []string{"codeagent-wrapper", "--output", " /tmp/out.json ", "task"}, + want: "/tmp/out.json", + }, + { + name: "output missing value", + args: []string{"codeagent-wrapper", "--output"}, + wantErr: true, + }, + { + name: "output equals missing value", + args: []string{"codeagent-wrapper", "--output=", "task"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Args = tt.args + cfg, err := parseArgs() + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.OutputPath != tt.want { + t.Fatalf("OutputPath = %q, want %q", cfg.OutputPath, tt.want) + } + }) + } +} + func TestBackendParseArgs_SkipPermissions(t *testing.T) { const envKey = "CODEAGENT_SKIP_PERMISSIONS" t.Setenv(envKey, "true") @@ -3751,6 +3805,245 @@ noop`) } } +func TestRunSingleWithOutputFile(t *testing.T) { + defer resetTestHooks() + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "single-output.json") + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"codeagent-wrapper", "--output", outputPath, "task"} + + stdinReader = strings.NewReader("") + isTerminalFn = func() bool { return true } + + origRunTaskFn := runTaskFn + runTaskFn = func(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult { + return TaskResult{ + TaskID: "single-task", + ExitCode: 0, + Message: "single-result", + SessionID: "sid-single", + } + } + t.Cleanup(func() { runTaskFn = origRunTaskFn }) + + if code := run(); code != 0 { + t.Fatalf("run exit = %d, want 0", code) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + t.Fatalf("output file should end with newline, got %q", string(data)) + } + + var payload struct { + Results []TaskResult `json:"results"` + Summary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + } `json:"summary"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("failed to unmarshal output json: %v", err) + } + + if payload.Summary.Total != 1 || payload.Summary.Success != 1 || payload.Summary.Failed != 0 { + t.Fatalf("unexpected summary: %+v", payload.Summary) + } + if len(payload.Results) != 1 { + t.Fatalf("results length = %d, want 1", len(payload.Results)) + } + if payload.Results[0].Message != "single-result" { + t.Fatalf("result message = %q, want %q", payload.Results[0].Message, "single-result") + } +} + +func TestRunSingleWithOutputFileOnFailureExitCode(t *testing.T) { + defer resetTestHooks() + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "single-output-failed.json") + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"codeagent-wrapper", "--output", outputPath, "task"} + + stdinReader = strings.NewReader("") + isTerminalFn = func() bool { return true } + + origRunTaskFn := runTaskFn + runTaskFn = func(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult { + return TaskResult{ + TaskID: "single-task", + ExitCode: 7, + Message: "failed-result", + Error: "backend error", + } + } + t.Cleanup(func() { runTaskFn = origRunTaskFn }) + + if code := run(); code != 7 { + t.Fatalf("run exit = %d, want 7", code) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + t.Fatalf("output file should end with newline, got %q", string(data)) + } + + var payload struct { + Results []TaskResult `json:"results"` + Summary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + } `json:"summary"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("failed to unmarshal output json: %v", err) + } + + if payload.Summary.Total != 1 || payload.Summary.Success != 0 || payload.Summary.Failed != 1 { + t.Fatalf("unexpected summary: %+v", payload.Summary) + } + if len(payload.Results) != 1 { + t.Fatalf("results length = %d, want 1", len(payload.Results)) + } + if payload.Results[0].ExitCode != 7 { + t.Fatalf("result exit_code = %d, want 7", payload.Results[0].ExitCode) + } +} + +func TestRunSingleWithOutputFileOnEmptyMessage(t *testing.T) { + defer resetTestHooks() + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "single-output-empty.json") + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"codeagent-wrapper", "--output", outputPath, "task"} + + stdinReader = strings.NewReader("") + isTerminalFn = func() bool { return true } + + origRunTaskFn := runTaskFn + runTaskFn = func(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult { + return TaskResult{ + TaskID: "single-task", + ExitCode: 0, + } + } + t.Cleanup(func() { runTaskFn = origRunTaskFn }) + + if code := run(); code != 1 { + t.Fatalf("run exit = %d, want 1", code) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + t.Fatalf("output file should end with newline, got %q", string(data)) + } + + var payload struct { + Results []TaskResult `json:"results"` + Summary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + } `json:"summary"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("failed to unmarshal output json: %v", err) + } + + if payload.Summary.Total != 1 || payload.Summary.Success != 0 || payload.Summary.Failed != 1 { + t.Fatalf("unexpected summary: %+v", payload.Summary) + } + if len(payload.Results) != 1 { + t.Fatalf("results length = %d, want 1", len(payload.Results)) + } + if !strings.Contains(payload.Results[0].Error, "no output message:") { + t.Fatalf("result error = %q, want no output message", payload.Results[0].Error) + } +} + +func TestRunParallelWithOutputFile(t *testing.T) { + defer resetTestHooks() + cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil } + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "parallel-output.json") + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"codeagent-wrapper", "--parallel", "--output", outputPath} + + stdinReader = strings.NewReader(`---TASK--- +id: T1 +---CONTENT--- +noop`) + t.Cleanup(func() { stdinReader = os.Stdin }) + + origRunCodexTaskFn := runCodexTaskFn + runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { + return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "parallel output marker"} + } + t.Cleanup(func() { runCodexTaskFn = origRunCodexTaskFn }) + + out := captureOutput(t, func() { + if code := run(); code != 0 { + t.Fatalf("run exit = %d, want 0", code) + } + }) + + if !strings.Contains(out, "=== Execution Report ===") { + t.Fatalf("stdout should keep summary format, got %q", out) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + t.Fatalf("output file should end with newline, got %q", string(data)) + } + + var payload struct { + Results []TaskResult `json:"results"` + Summary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + } `json:"summary"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("failed to unmarshal output json: %v", err) + } + + if payload.Summary.Total != 1 || payload.Summary.Success != 1 || payload.Summary.Failed != 0 { + t.Fatalf("unexpected summary: %+v", payload.Summary) + } + if len(payload.Results) != 1 { + t.Fatalf("results length = %d, want 1", len(payload.Results)) + } + if payload.Results[0].TaskID != "T1" { + t.Fatalf("result task_id = %q, want %q", payload.Results[0].TaskID, "T1") + } +} + func TestParallelInvalidBackend(t *testing.T) { defer resetTestHooks() cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil } diff --git a/codeagent-wrapper/internal/app/output_file.go b/codeagent-wrapper/internal/app/output_file.go new file mode 100644 index 0000000..3e6f6d3 --- /dev/null +++ b/codeagent-wrapper/internal/app/output_file.go @@ -0,0 +1,65 @@ +package wrapper + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goccy/go-json" +) + +type outputSummary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` +} + +type outputPayload struct { + Results []TaskResult `json:"results"` + Summary outputSummary `json:"summary"` +} + +func writeStructuredOutput(path string, results []TaskResult) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + + cleanPath := filepath.Clean(path) + dir := filepath.Dir(cleanPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory for %q: %w", cleanPath, err) + } + + f, err := os.Create(cleanPath) + if err != nil { + return fmt.Errorf("failed to create output file %q: %w", cleanPath, err) + } + + encodeErr := json.NewEncoder(f).Encode(outputPayload{ + Results: results, + Summary: summarizeResults(results), + }) + closeErr := f.Close() + + if encodeErr != nil { + return fmt.Errorf("failed to write structured output to %q: %w", cleanPath, encodeErr) + } + if closeErr != nil { + return fmt.Errorf("failed to close output file %q: %w", cleanPath, closeErr) + } + return nil +} + +func summarizeResults(results []TaskResult) outputSummary { + summary := outputSummary{Total: len(results)} + for _, res := range results { + if res.ExitCode == 0 && res.Error == "" { + summary.Success++ + } else { + summary.Failed++ + } + } + return summary +} diff --git a/codeagent-wrapper/internal/config/config.go b/codeagent-wrapper/internal/config/config.go index 9778513..cea47e0 100644 --- a/codeagent-wrapper/internal/config/config.go +++ b/codeagent-wrapper/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { Task string SessionID string WorkDir string + OutputPath string Model string ReasoningEffort string ExplicitStdin bool