@@ -68,6 +68,13 @@ def generate_result(candidates)
6868 )
6969 end
7070
71+ def structured_generate_result ( candidates )
72+ {
73+ 'summary' => 'MR recalculates order totals during checkout.' ,
74+ 'candidates' => candidates . map { |candidate | candidate . transform_keys ( &:to_s ) }
75+ }
76+ end
77+
7178 it 'renders only candidates accepted by critique' do
7279 allow ( reviewer ) . to receive ( :generate ) . and_return ( generate_result ( candidates ) )
7380 allow ( reviewer ) . to receive ( :critique ) . and_return (
@@ -104,6 +111,95 @@ def generate_result(candidates)
104111 expect ( result ) . to include ( 'ok' )
105112 end
106113
114+ it 'processes structured Hash responses without JSON parsing or repair' do
115+ allow ( reviewer ) . to receive ( :generate ) . and_return ( structured_generate_result ( [ candidates . first ] ) )
116+ allow ( reviewer ) . to receive ( :critique ) . and_return (
117+ {
118+ 'verdicts' => [
119+ { 'id' => 'C1' , 'decision' => 'keep' , 'reason' => 'confirmed by diff' }
120+ ]
121+ }
122+ )
123+ allow ( JSON ) . to receive ( :parse ) . and_call_original
124+
125+ result = pipeline . run ( merge_request : merge_request , changes_text : changes_text )
126+
127+ expect ( JSON ) . not_to have_received ( :parse )
128+ expect ( reviewer ) . to have_received ( :generate ) . once
129+ expect ( reviewer ) . to have_received ( :critique ) . once
130+ expect ( result ) . to include ( 'Tax is no longer included' )
131+ end
132+
133+ it 'continues processing JSON strings without repair' do
134+ allow ( reviewer ) . to receive ( :generate ) . and_return ( generate_result ( [ candidates . first ] ) )
135+
136+ result = pipeline . run ( merge_request : merge_request , changes_text : changes_text , critique : false )
137+
138+ expect ( reviewer ) . to have_received ( :generate ) . once
139+ expect ( result ) . to include ( 'Tax is no longer included' )
140+ end
141+
142+ it 'rejects an invalid structured generate result without repair' do
143+ invalid_result = structured_generate_result ( [ { file : 'app/models/order.rb' } ] )
144+ allow ( reviewer ) . to receive ( :generate ) . and_return ( invalid_result )
145+
146+ expect do
147+ pipeline . run ( merge_request : merge_request , changes_text : changes_text , critique : false )
148+ end . to raise_error (
149+ Aireview ::ParseError ,
150+ /invalid generate result: each generate candidate must include a non-empty id/
151+ )
152+ expect ( reviewer ) . to have_received ( :generate ) . once
153+ end
154+
155+ it 'rejects malformed structured generate candidates without repair' do
156+ allow ( reviewer ) . to receive ( :generate ) . and_return (
157+ { 'summary' => 'Malformed result' , 'candidates' => [ nil ] }
158+ )
159+
160+ expect do
161+ pipeline . run ( merge_request : merge_request , changes_text : changes_text , critique : false )
162+ end . to raise_error (
163+ Aireview ::ParseError ,
164+ /invalid generate result: each generate candidate must be an object/
165+ )
166+ expect ( reviewer ) . to have_received ( :generate ) . once
167+ end
168+
169+ it 'rejects an unsupported response type without repair' do
170+ allow ( reviewer ) . to receive ( :generate ) . and_return ( nil )
171+
172+ expect do
173+ pipeline . run ( merge_request : merge_request , changes_text : changes_text , critique : false )
174+ end . to raise_error ( Aireview ::ParseError , /unsupported generate result type: NilClass/ )
175+ expect ( reviewer ) . to have_received ( :generate ) . once
176+ end
177+
178+ it 'rejects an invalid structured critique result without repair' do
179+ allow ( reviewer ) . to receive ( :generate ) . and_return ( structured_generate_result ( [ candidates . first ] ) )
180+ allow ( reviewer ) . to receive ( :critique ) . and_return (
181+ { 'verdicts' => [ { 'id' => 'C9' , 'decision' => 'keep' , 'reason' => 'hallucinated id' } ] }
182+ )
183+
184+ expect do
185+ pipeline . run ( merge_request : merge_request , changes_text : changes_text )
186+ end . to raise_error ( Aireview ::ParseError , /invalid critique result: unknown verdict ids: C9/ )
187+ expect ( reviewer ) . to have_received ( :critique ) . once
188+ end
189+
190+ it 'rejects malformed structured critique verdicts without repair' do
191+ allow ( reviewer ) . to receive ( :generate ) . and_return ( structured_generate_result ( [ candidates . first ] ) )
192+ allow ( reviewer ) . to receive ( :critique ) . and_return ( { 'verdicts' => [ 42 ] } )
193+
194+ expect do
195+ pipeline . run ( merge_request : merge_request , changes_text : changes_text )
196+ end . to raise_error (
197+ Aireview ::ParseError ,
198+ /invalid critique result: each critique verdict must be an object/
199+ )
200+ expect ( reviewer ) . to have_received ( :critique ) . once
201+ end
202+
107203 it 'repairs invalid generate JSON once' do
108204 allow ( reviewer ) . to receive ( :generate ) . and_return ( 'not json' , generate_result ( [ candidates . first ] ) )
109205
@@ -113,6 +209,15 @@ def generate_result(candidates)
113209 expect ( result ) . to include ( 'Tax is no longer included' )
114210 end
115211
212+ it 'accepts a structured Hash returned by a repair request for a JSON string' do
213+ allow ( reviewer ) . to receive ( :generate ) . and_return ( 'not json' , structured_generate_result ( [ candidates . first ] ) )
214+
215+ result = pipeline . run ( merge_request : merge_request , changes_text : changes_text , critique : false )
216+
217+ expect ( reviewer ) . to have_received ( :generate ) . twice
218+ expect ( result ) . to include ( 'Tax is no longer included' )
219+ end
220+
116221 it 'repairs generate candidates with invalid ids once' do
117222 invalid_generate = JSON . generate ( summary : 'bad' , candidates : [ { file : 'app/models/order.rb' } ] )
118223 allow ( reviewer ) . to receive ( :generate ) . and_return ( invalid_generate , generate_result ( [ candidates . first ] ) )
0 commit comments