Skip to content

Commit 479db7a

Browse files
committed
Fix opencode PRD conversion by extracting JSON from NDJSON output
1 parent 1712bec commit 479db7a

7 files changed

Lines changed: 61 additions & 14 deletions

File tree

internal/agent/claude.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,6 @@ func (p *ClaudeProvider) ParseLine(line string) *loop.Event {
6868

6969
// LogFileName implements loop.Provider.
7070
func (p *ClaudeProvider) LogFileName() string { return "claude.log" }
71+
72+
// CleanOutput implements loop.Provider - Claude doesn't use a special format.
73+
func (p *ClaudeProvider) CleanOutput(output string) string { return output }

internal/agent/codex.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@ func (p *CodexProvider) ParseLine(line string) *loop.Event {
7979

8080
// LogFileName implements loop.Provider.
8181
func (p *CodexProvider) LogFileName() string { return "codex.log" }
82+
83+
// CleanOutput implements loop.Provider - Codex doesn't use a special format.
84+
func (p *CodexProvider) CleanOutput(output string) string { return output }

internal/agent/opencode.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package agent
22

33
import (
44
"context"
5+
"encoding/json"
56
"os/exec"
67
"strings"
78

@@ -53,3 +54,32 @@ func (p *OpenCodeProvider) ParseLine(line string) *loop.Event {
5354
}
5455

5556
func (p *OpenCodeProvider) LogFileName() string { return "opencode.log" }
57+
58+
// CleanOutput extracts JSON from opencode's NDJSON output format.
59+
func (p *OpenCodeProvider) CleanOutput(output string) string {
60+
output = strings.TrimSpace(output)
61+
62+
if !strings.Contains(output, "\n") || !strings.Contains(output, `"type":"step_start"`) || !strings.Contains(output, `"type":"text"`) {
63+
return output
64+
}
65+
66+
lines := strings.Split(output, "\n")
67+
for _, line := range lines {
68+
line = strings.TrimSpace(line)
69+
if line == "" {
70+
continue
71+
}
72+
if strings.Contains(line, `"type":"text"`) {
73+
var ev struct {
74+
Type string `json:"type"`
75+
Part struct {
76+
Text string `json:"text"`
77+
} `json:"part"`
78+
}
79+
if json.Unmarshal([]byte(line), &ev) == nil && ev.Part.Text != "" {
80+
return ev.Part.Text
81+
}
82+
}
83+
}
84+
return output
85+
}

internal/cmd/new.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ func RunConvertWithOptions(opts ConvertOptions) error {
120120
Merge: opts.Merge,
121121
Force: opts.Force,
122122
RunConversion: func(absPRDDir string) (string, error) {
123-
return runConversionWithProvider(provider, absPRDDir)
123+
raw, err := runConversionWithProvider(provider, absPRDDir)
124+
if err != nil {
125+
return "", err
126+
}
127+
return provider.CleanOutput(raw), nil
124128
},
125129
RunFixJSON: func(prompt string) (string, error) {
126-
return runFixJSONWithProvider(provider, prompt)
130+
raw, err := runFixJSONWithProvider(provider, prompt)
131+
if err != nil {
132+
return "", err
133+
}
134+
return provider.CleanOutput(raw), nil
127135
},
128136
})
129137
}

internal/loop/provider.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type Provider interface {
2424
InteractiveCommand(workDir, prompt string) *exec.Cmd
2525
ConvertCommand(workDir, prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error)
2626
FixJSONCommand(prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error)
27+
// CleanOutput extracts JSON from the provider's output format (e.g., NDJSON).
28+
// Returns the original output if no cleaning needed.
29+
CleanOutput(output string) string
2730
ParseLine(line string) *Event
2831
LogFileName() string
2932
}

internal/prd/generator.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ var waitingJokes = []string{
5454

5555
// ConvertOptions contains configuration for PRD conversion.
5656
type ConvertOptions struct {
57-
PRDDir string // Directory containing prd.md
58-
Merge bool // Auto-merge progress on conversion conflicts
59-
Force bool // Auto-overwrite on conversion conflicts
57+
PRDDir string // Directory containing prd.md
58+
Merge bool // Auto-merge progress on conversion conflicts
59+
Force bool // Auto-overwrite on conversion conflicts
6060
// RunConversion runs the agent to convert prd.md to JSON. Required.
6161
RunConversion func(absPRDDir string) (string, error)
6262
// RunFixJSON runs the agent to fix invalid JSON. Required.
@@ -117,7 +117,7 @@ func Convert(opts ConvertOptions) error {
117117
}
118118

119119
// Clean up output (strip markdown fences if any)
120-
cleanedJSON := cleanJSONOutput(rawJSON)
120+
cleanedJSON := stripMarkdownFences(rawJSON)
121121

122122
// Parse and validate
123123
newPRD, err := parseAndValidatePRD(cleanedJSON)
@@ -130,7 +130,7 @@ func Convert(opts ConvertOptions) error {
130130
return fmt.Errorf("conversion retry failed: %w", retryErr)
131131
}
132132

133-
cleanedJSON = cleanJSONOutput(fixedJSON)
133+
cleanedJSON = stripMarkdownFences(fixedJSON)
134134
newPRD, err = parseAndValidatePRD(cleanedJSON)
135135
if err != nil {
136136
return fmt.Errorf("conversion produced invalid JSON after retry:\n---\n%s\n---\n%w", cleanedJSON, err)
@@ -555,9 +555,9 @@ func NeedsConversion(prdDir string) (bool, error) {
555555
return mdInfo.ModTime().After(jsonInfo.ModTime()), nil
556556
}
557557

558-
// cleanJSONOutput removes markdown code blocks, conversational preamble, and trims
559-
// whitespace from Claude's output to extract the JSON object.
560-
func cleanJSONOutput(output string) string {
558+
// stripMarkdownFences removes markdown code blocks and extracts the JSON object.
559+
// This handles output from providers like Claude that may wrap JSON in markdown fences.
560+
func stripMarkdownFences(output string) string {
561561
output = strings.TrimSpace(output)
562562

563563
// Remove markdown code blocks if present
@@ -573,7 +573,7 @@ func cleanJSONOutput(output string) string {
573573

574574
output = strings.TrimSpace(output)
575575

576-
// If output doesn't start with '{', Claude may have added preamble text.
576+
// If output doesn't start with '{', the provider may have added preamble text.
577577
// Extract the JSON object by finding the first '{' and matching closing '}'.
578578
if len(output) > 0 && output[0] != '{' {
579579
start := strings.Index(output, "{")

internal/prd/generator_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"time"
99
)
1010

11-
func TestCleanJSONOutput(t *testing.T) {
11+
func TestStripMarkdownFences(t *testing.T) {
1212
tests := []struct {
1313
name string
1414
input string
@@ -63,9 +63,9 @@ func TestCleanJSONOutput(t *testing.T) {
6363

6464
for _, tt := range tests {
6565
t.Run(tt.name, func(t *testing.T) {
66-
result := cleanJSONOutput(tt.input)
66+
result := stripMarkdownFences(tt.input)
6767
if result != tt.expected {
68-
t.Errorf("cleanJSONOutput() = %q, want %q", result, tt.expected)
68+
t.Errorf("stripMarkdownFences() = %q, want %q", result, tt.expected)
6969
}
7070
})
7171
}

0 commit comments

Comments
 (0)