mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-28 09:23:05 +08:00
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 <danielee.eth@gmail.com> Co-authored-by: cexll <evanxian9@gmail.com>
This commit is contained in:
@@ -29,6 +29,7 @@ type cliOptions struct {
|
|||||||
ReasoningEffort string
|
ReasoningEffort string
|
||||||
Agent string
|
Agent string
|
||||||
PromptFile string
|
PromptFile string
|
||||||
|
Output string
|
||||||
Skills string
|
Skills string
|
||||||
SkipPermissions bool
|
SkipPermissions bool
|
||||||
Worktree 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.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)")
|
||||||
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
|
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
|
||||||
fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path")
|
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.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)")
|
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 := ""
|
agentName := ""
|
||||||
promptFile := ""
|
promptFile := ""
|
||||||
promptFileExplicit := false
|
promptFileExplicit := false
|
||||||
|
outputPath := ""
|
||||||
yolo := false
|
yolo := false
|
||||||
|
|
||||||
if cmd.Flags().Changed("agent") {
|
if cmd.Flags().Changed("agent") {
|
||||||
@@ -281,6 +284,15 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
|
|||||||
promptFile = resolvedPromptFile
|
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")
|
agentFlagChanged := cmd.Flags().Changed("agent")
|
||||||
backendFlagChanged := cmd.Flags().Changed("backend")
|
backendFlagChanged := cmd.Flags().Changed("backend")
|
||||||
if backendFlagChanged {
|
if backendFlagChanged {
|
||||||
@@ -357,6 +369,7 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
|
|||||||
Agent: agentName,
|
Agent: agentName,
|
||||||
PromptFile: promptFile,
|
PromptFile: promptFile,
|
||||||
PromptFileExplicit: promptFileExplicit,
|
PromptFileExplicit: promptFileExplicit,
|
||||||
|
OutputPath: outputPath,
|
||||||
SkipPermissions: skipPermissions,
|
SkipPermissions: skipPermissions,
|
||||||
Yolo: yolo,
|
Yolo: yolo,
|
||||||
Model: model,
|
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") {
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +476,17 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip
|
|||||||
fullOutput = v.GetBool("full-output")
|
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")
|
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
|
||||||
skipPermissions := false
|
skipPermissions := false
|
||||||
if skipChanged {
|
if skipChanged {
|
||||||
@@ -525,6 +549,11 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip
|
|||||||
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
|
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))
|
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
|
||||||
|
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
@@ -688,16 +717,25 @@ func runSingleMode(cfg *Config, name string) int {
|
|||||||
|
|
||||||
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
exitCode := result.ExitCode
|
||||||
return 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 err := writeStructuredOutput(cfg.OutputPath, []TaskResult{result}); err != nil {
|
||||||
if strings.TrimSpace(result.Message) == "" {
|
logError(err.Error())
|
||||||
logError(fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend))
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
return exitCode
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println(result.Message)
|
fmt.Println(result.Message)
|
||||||
if result.SessionID != "" {
|
if result.SessionID != "" {
|
||||||
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
||||||
|
|||||||
@@ -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) {
|
func TestBackendParseArgs_SkipPermissions(t *testing.T) {
|
||||||
const envKey = "CODEAGENT_SKIP_PERMISSIONS"
|
const envKey = "CODEAGENT_SKIP_PERMISSIONS"
|
||||||
t.Setenv(envKey, "true")
|
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) {
|
func TestParallelInvalidBackend(t *testing.T) {
|
||||||
defer resetTestHooks()
|
defer resetTestHooks()
|
||||||
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
||||||
|
|||||||
65
codeagent-wrapper/internal/app/output_file.go
Normal file
65
codeagent-wrapper/internal/app/output_file.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
Task string
|
Task string
|
||||||
SessionID string
|
SessionID string
|
||||||
WorkDir string
|
WorkDir string
|
||||||
|
OutputPath string
|
||||||
Model string
|
Model string
|
||||||
ReasoningEffort string
|
ReasoningEffort string
|
||||||
ExplicitStdin bool
|
ExplicitStdin bool
|
||||||
|
|||||||
Reference in New Issue
Block a user