Skip to content

fix(opencode): extract reviewer JSON from prose-prefixed output - #2099

Open
umi008 wants to merge 2 commits into
Gentleman-Programming:mainfrom
umi008:fix/issue-1789-strip-prose-before-json
Open

fix(opencode): extract reviewer JSON from prose-prefixed output#2099
umi008 wants to merge 2 commits into
Gentleman-Programming:mainfrom
umi008:fix/issue-1789-strip-prose-before-json

Conversation

@umi008

@umi008 umi008 commented Jul 31, 2026

Copy link
Copy Markdown

🔗 Linked Issue

Closes #1789


🏷️ PR Type

  • type:bug — Bug fix (non-breaking change that fixes an issue)

📝 Summary

The managed OpenCode capture hook (review-result-artifacts.ts) forwarded reviewer output verbatim to review capture-result. When a reviewer prefixed explanatory prose before the JSON envelope (or wrapped it in ```json fences), the native strict decoder failed with invalid character ... looking for beginning of value and the lens result was stranded as a preserved `.raw` incident, blocking the review budget. This is the same root cause as #1579 (closed by #1550) regressed on v2.1.11.

The fix runs both the bare output and the extracted task-envelope body through a new extractReviewerJson() that strips markdown fences and locates the single well-formed JSON object via brace-depth scanning (string-aware), verified with JSON.parse. Output with no JSON object at all still fails closed and is preserved as an incident, so recovery behavior is unchanged.


📂 Changes

File / Area What Changed
internal/assets/opencode/plugins/review-result-artifacts.ts reviewerResult() now extracts the JSON object from prose-prefixed/fenced output instead of forwarding it verbatim
internal/assets/review_plugin_recovery_test.go Harness scenarios proving the exact extracted JSON reaches capture-result byte-for-byte (prose-prefixed, fenced, enveloped-prose) and that prose-only output stays preserved

🧪 Test Plan

Unit Tests

go test ./...

Go Format

go run ./internal/gofmtcheck
  • Passes

E2E Tests (Docker required)

  • Not run (Docker unavailable locally)

  • Unit tests pass (go test ./...)

  • Go format passes (go run ./internal/gofmtcheck)

  • E2E tests pass (cd e2e && ./docker-test.sh)

  • Manually tested locally (extraction verified against the preserved .raw incident from the report)


✅ Contributor Checklist

  • PR is linked to an issue with status:approved
  • PR stays within 400 changed lines, or I have requested/obtained maintainer-applied size:exception with rationale documented
  • I have added the appropriate type:* label to this PR
  • Unit tests pass (go test ./...)
  • E2E tests pass (cd e2e && ./docker-test.sh)
  • I have updated documentation if necessary
  • My commits follow Conventional Commits format
  • My commits do not include Co-Authored-By trailers

Summary by CodeRabbit

  • Bug Fixes
    • Review results now reliably extract valid JSON from task output, including responses preceded by explanatory text or wrapped in Markdown code fences.
    • Nested objects, quoted braces, and escaped characters are handled correctly during extraction.
    • Invalid, empty, or prose-only responses now fail safely instead of being forwarded as malformed review results.
    • Existing validation for malformed task envelopes and capture failures remains enforced.

review-result-artifacts.ts forwarded reviewer output verbatim to review
capture-result, so a reviewer that prefixed explanatory prose (or wrapped
the object in markdown fences) before the JSON envelope made the native
strict decoder fail with "invalid character ... looking for beginning of
value" and stranded the lens result as a preserved incident (regression
of Gentleman-Programming#1579, reported as Gentleman-Programming#1789).

reviewerResult now runs both the bare output and the extracted task
envelope body through extractReviewerJson, which strips markdown fences
and locates the single well-formed JSON object by brace-depth scanning
that respects string contents, then verifies the slice with JSON.parse.
Prose-only output still fails closed and is preserved as an incident.

Adds harness scenarios proving the exact extracted JSON reaches
capture-result byte for byte for prose-prefixed, fenced, and
enveloped-prose output, and that prose-only output stays recoverable.
Copilot AI review requested due to automatic review settings July 31, 2026 15:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OpenCode review hook now extracts a valid JSON object from reviewer output before capture. It handles prose prefixes, Markdown fences, nested task envelopes, strings, and escapes. Recovery tests verify exact capture payloads and fail-closed behavior.

Changes

Reviewer JSON recovery

Layer / File(s) Summary
Reviewer JSON extraction
internal/assets/opencode/plugins/review-result-artifacts.ts
The hook extracts one balanced, parseable JSON object after existing task-envelope validation. It supports prose, Markdown fences, quoted strings, and escapes.
Recovery harness validation
internal/assets/review_plugin_recovery_test.go
The test harness checks exact capture input for prose-prefixed, fenced, and task-enveloped output. It also verifies prose-only and capture-failure recovery behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Reviewer
  participant reviewerResult
  participant extractReviewerJson
  participant CaptureResult
  Reviewer->>reviewerResult: Reviewer task output
  reviewerResult->>extractReviewerJson: Unwrapped result
  extractReviewerJson->>reviewerResult: Valid JSON object
  reviewerResult->>CaptureResult: Extracted payload
  CaptureResult-->>reviewerResult: Capture status
Loading

Possibly related PRs

Suggested labels: type:bug

Suggested reviewers: copilot, alan-thegentleman

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: extracting reviewer JSON from prose-prefixed OpenCode output.
Linked Issues check ✅ Passed The implementation extracts valid JSON from prose, fences, and envelopes, and fails closed when no object exists, meeting issue #1789.
Out of Scope Changes check ✅ Passed The code and test changes directly support reviewer JSON extraction and recovery for issue #1789.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/assets/opencode/plugins/review-result-artifacts.ts`:
- Around line 123-153: Update the JSON-object scanner around the candidate loop
so each “{” begins an independent scan with depth, inString, and escaped reset,
preventing unmatched quotes in leading prose from affecting later objects.
Preserve balanced-object parsing and continue scanning after invalid candidates,
and add a regression case covering an unmatched quote in the prose prefix before
a valid JSON object.

In `@internal/assets/review_plugin_recovery_test.go`:
- Around line 119-124: Update the recovery test stub and its setup to compare
capture input byte-for-byte: write expectStdin to a temporary expected file in
Go, expose its path through GENTLE_AI_STUB_EXPECT_STDIN_FILE, and replace the
stdin=$(cat) string comparison in the stub with cmp -s
"$GENTLE_AI_STUB_EXPECT_STDIN_FILE" -. Preserve the existing success and
mismatch outputs while ensuring trailing newlines remain significant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7921693d-c3ce-4c12-98e2-b40d625bed87

📥 Commits

Reviewing files that changed from the base of the PR and between 919ea3d and 42092db.

📒 Files selected for processing (2)
  • internal/assets/opencode/plugins/review-result-artifacts.ts
  • internal/assets/review_plugin_recovery_test.go

Comment thread internal/assets/opencode/plugins/review-result-artifacts.ts Outdated
Comment thread internal/assets/review_plugin_recovery_test.go
CodeRabbit review of PR Gentleman-Programming#2099 found two correctness gaps:

1. The single-pass scanner carried string state from leading prose into
   the object scan: an unmatched double quote in the prose set inString
   and made the scanner ignore the real JSON object that followed.
   extractReviewerJson now treats every '{' as an independent candidate
   with fresh depth/string state, and the harness prose is adversarial
   (unmatched quote plus stray balanced braces) to pin the regression.

2. The stub compared stdin via $(cat), which strips trailing newlines,
   so the byte-for-byte claim was not honest. The stub now compares with
   cmp against an expected file, and re-consumes stdin on the failure
   paths so the plugin never sees an EPIPE instead of the native stderr.
Copilot AI review requested due to automatic review settings July 31, 2026 15:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/assets/opencode/plugins/review-result-artifacts.ts`:
- Around line 121-147: Bound the brace-scanning loop in the reviewer-output
extraction logic before attempting JSON parsing, using the existing reviewer
artifact admission limit or an equivalent scan-work cap. When the limit is
exceeded, preserve the raw response and route it through the existing
extraction-failure path rather than continuing quadratic scanning. Add a
regression case covering a large malformed brace prefix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 75e37b01-2c94-4a28-a54f-efc9876b64fb

📥 Commits

Reviewing files that changed from the base of the PR and between 42092db and e80f20c.

📒 Files selected for processing (2)
  • internal/assets/opencode/plugins/review-result-artifacts.ts
  • internal/assets/review_plugin_recovery_test.go

Comment on lines +121 to +147
for (let start = 0; start < candidate.length; start++) {
if (candidate[start] !== "{") continue
let depth = 1
let inString = false
let escaped = false
let i = start + 1
for (; i < candidate.length; i++) {
const ch = candidate[i]
if (inString) {
if (escaped) escaped = false
else if (ch === "\\") escaped = true
else if (ch === '"') inString = false
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === "{") {
depth++
continue
}
if (ch === "}") {
depth--
if (depth === 0) break
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound the candidate scan before parsing reviewer output.

Line 121 starts a full suffix scan for every {. A response containing N opening braces performs quadratic work before Line 158 throws. This occurs before review capture-result processes the result, so a large malformed reviewer response can block the after hook.

Add a source-size or scan-work limit that matches reviewer artifact admission. Preserve the raw response through the existing extraction-failure path when the limit is exceeded. Add a regression case with a large malformed brace prefix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/assets/opencode/plugins/review-result-artifacts.ts` around lines 121
- 147, Bound the brace-scanning loop in the reviewer-output extraction logic
before attempting JSON parsing, using the existing reviewer artifact admission
limit or an equivalent scan-work cap. When the limit is exceeded, preserve the
raw response and route it through the existing extraction-failure path rather
than continuing quadratic scanning. Add a regression case covering a large
malformed brace prefix.

@miguelpuente

Copy link
Copy Markdown

Independent reproductions that strengthen the evidence this PR is the right fix.

Environment

  • Gentle AI: 2.2.4 (stable ELF)
  • OS: Linux x86_64 (Ubuntu 24.04)
  • Agent: OpenCode with managed review-result-artifacts.ts hook
  • Repo: miguelpuente/RAG (Django ERP), 3 PRs from the 2026-08-03 cobros sprint

Reproduction (12 lens invocations, all rejected pre-fix)

For each of the 3 PRs in the sprint, the 4R set was invoked (risk / resilience / readability / reliability). All 12 lens sub-agents returned their JSON wrapped in json ... fences. The native admission rejected every capture with reviewer artifact admission incomplete: and preserved the raw payloads at .git/gentle-ai/review-transactions/incidents/<lineage>/NN-review-LENS-<hash>.raw.

Sample of one preserved raw output (PR #232 reliability, first lens):

$ head -c 220 .git/gentle-ai/review-transactions/incidents/review-8495693e29343476/00-review-risk-acc216b468f1.raw
\`\`\`json
{"findings":[],"evidence":["Inspected .github/workflows/design-system.yml and django-tests.yml..."]}
\`\`\`

After manually extracting the JSON, the lens did have substantive findings:

$ python3 -c "
import json, re, glob
for f in glob.glob('.git/gentle-ai/review-transactions/incidents/review-*/*.raw'):
    raw = open(f).read()
    m = re.search(r'\`\`\`(?:json)?\s*(\{.*?\})\s*\`\`\`', raw, re.DOTALL)
    if m:
        j = json.loads(m.group(1))
        n = len(j.get('findings', []))
        sev = ','.join(f.get('severity','?') for f in j.get('findings',[]))
        print(f'{f.split(\"/\")[-1][:50]}: {n} findings [{sev}]')
"
# ... 1 CRITICAL + 2 WARNING + several SUGGESTION findings across the 12 lenses

So the pre-fix behavior lost real review signal. This PR's extractReviewerJson() is exactly what's needed.

Style note that helps both reviewers and PR-#2277

Your helper uses brace-balancing with proper string escaping — that's the right primitive. A simpler ^```(?:json)?\s*\n([\s\S]*?)\n\s*```$ regex would have sufficed for the canonical fence shapes I observed, but it would have missed:

  • <task id="…" state="completed"><task_result>\n\``json\n{...}\n```\n</task_result>envelopes (the harness exercises this inafter-envelope-prose`).
  • Multiple nested {/} inside proof_refs array values where naive regex would mis-split.

Your extractReviewerJson handles both via the JSON.parse validation step. That's the right call — I'll mirror that approach in the cwd-override PR I'm drafting (#2446 / comment on #1886).

Suggestion (not blocking)

Consider promoting extractReviewerJson from an internal helper to an exported member so the cwd-override work in PR for #2446 can reuse the same "extract balanced JSON object" primitive when a future capture failure needs to preserve the strict payload (not the prose/fence-wrapped reviewer output). One small refactor, no behavior change for the existing fix.

Local workaround status

I shipped a less-robust local patch (stripMarkdownFence) at ~/.config/opencode/plugins/review-result-artifacts.ts to unblock the sprint. It applies ^```(?:json|JSON)?\s*\n?([\s\S]*?)\n?\s*```\s*$ to the envelope body — does not handle the prose-prefix shape and would fail your after-prose test. Will revert once this PR lands and the bundled asset gets refreshed via gga sync.

No PR from me in the same area. Keeping the review budget clean for your fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(opencode): review-result-artifacts.ts fails to strip leading prose before JSON again (regression of #1579, v2.1.11)

4 participants