From bf33e94e99f95853b193e4cbbb23306ef828c4ff Mon Sep 17 00:00:00 2001 From: Thando Date: Wed, 24 Jun 2026 07:26:45 +0200 Subject: [PATCH] fix(conflict): extract fenced file content from chatty LLM resolution replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports vortex-dispatch #91. The conflict resolver wrote the LLM's raw reply to the resolved file via stripCodeFences (which only trims leading/trailing fences). When the model wrapped the file in a ```fenced block with chatter, the whole reply got written — corrupting a real build's package.json into invalid JSON. extractResolvedFileContent now returns only the first fenced block's contents. Applied at both resolution sites; unit test pins the corrupting shape. Build + full suite green. --- internal/engine/conflict_extract_test.go | 35 ++++++++++++++++++++++++ internal/engine/conflict_resolver.go | 24 ++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 internal/engine/conflict_extract_test.go 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, "```") {