diff --git a/internal/engine/conflict_extract_test.go b/internal/engine/conflict_extract_test.go new file mode 100644 index 0000000..4e01eb8 --- /dev/null +++ b/internal/engine/conflict_extract_test.go @@ -0,0 +1,35 @@ +package engine + +import ( + "strings" + "testing" +) + +func TestExtractResolvedFileContent(t *testing.T) { + // The exact shape that corrupted a real client build's package.json: the + // model returned conversational preamble + a fenced block + postamble. + chatty := "Resolved. Kept `@playwright/test` (HEAD) — needed by test:e2e.\n\n" + + "Write blocked on permission. File content to apply:\n\n" + + "```json\n{\n \"name\": \"isiqalo-pos-frontend\",\n \"private\": true\n}\n```\n\n" + + "Grant write to apply, or paste yourself." + got := extractResolvedFileContent(chatty) + want := "{\n \"name\": \"isiqalo-pos-frontend\",\n \"private\": true\n}" + if got != want { + t.Fatalf("expected only the fenced JSON, got:\n%q", got) + } + if strings.Contains(got, "Resolved.") || strings.Contains(got, "Grant write") || strings.Contains(got, "```") { + t.Fatalf("extracted content still contains chatter/fences:\n%q", got) + } + + // A clean response (no fence) passes through unchanged. + clean := "package main\n\nfunc main() {}\n" + if g := extractResolvedFileContent(clean); strings.TrimSpace(g) != strings.TrimSpace(clean) { + t.Fatalf("clean content altered: %q", g) + } + + // A response that is ONLY a fenced block (no chatter) still yields its body. + fenced := "```go\npackage x\n```" + if g := extractResolvedFileContent(fenced); g != "package x" { + t.Fatalf("fenced-only extraction failed: %q", g) + } +} diff --git a/internal/engine/conflict_resolver.go b/internal/engine/conflict_resolver.go index 1b7d75e..1b81e15 100644 --- a/internal/engine/conflict_resolver.go +++ b/internal/engine/conflict_resolver.go @@ -355,7 +355,7 @@ File: %s return "", err } - resolved := stripCodeFences(resp.Content) + resolved := extractResolvedFileContent(resp.Content) // Sanity check: resolved content must not contain conflict markers. if strings.Contains(resolved, "<<<<<<<") || strings.Contains(resolved, ">>>>>>>") { @@ -431,7 +431,7 @@ CRITICAL OUTPUT RULES: return "", err } - resolved := stripCodeFences(resp.Content) + resolved := extractResolvedFileContent(resp.Content) if strings.Contains(resolved, "<<<<<<<") || strings.Contains(resolved, ">>>>>>>") { return "", fmt.Errorf("tech lead output still contains conflict markers") @@ -508,6 +508,26 @@ func truncateConflictContent(content string) string { } // stripCodeFences removes leading/trailing markdown code fences from LLM output. +// extractResolvedFileContent pulls the resolved file out of an LLM response. +// Conflict-resolution models sometimes wrap the file in a ```fenced block with +// conversational preamble/postamble ("Resolved. Kept X ... File content to +// apply: ```json {…}``` Grant write to apply."). Writing that whole reply +// verbatim corrupts the file (it broke a real build's package.json into invalid +// JSON). When a fenced block is present return ONLY its contents; otherwise +// fall back to trimming stray fences. +func extractResolvedFileContent(resp string) string { + if i := strings.Index(resp, "```"); i >= 0 { + rest := resp[i+3:] + if nl := strings.IndexByte(rest, '\n'); nl >= 0 { + rest = rest[nl+1:] + } + if j := strings.Index(rest, "```"); j >= 0 { + return strings.TrimSpace(rest[:j]) + } + } + return strings.TrimSpace(stripCodeFences(resp)) +} + func stripCodeFences(s string) string { s = strings.TrimSpace(s) if strings.HasPrefix(s, "```") {