import asyncio import copy import json import pytest from test_case_builder import case_setup from test_investigation_tools import tool_case from test_case_interpreter import gateway from test_investigator import LoopAdapter,CASE,state from test_working_brief import service_for from test_project_research import rows from viano_bug_analysis.analysis import Investigator,InvestigationConfig from viano_bug_analysis.budget import BudgetManager from viano_bug_analysis.lifecycle import Workspace,CheckpointStore from viano_bug_analysis.llm import ProviderResponse,LLMError from viano_bug_analysis.llm.response_recovery import normalize,parse_text,MARKER,REPAIR_PROMPT,apply_repair,sha from viano_bug_analysis.package import PackageReader,PackageValidator from viano_bug_analysis.tools import InvestigationToolRouter def loop(tool_case,adapter): return Investigator(tool_case[0],gateway(adapter),InvestigationConfig(response_recovery_version=1), router=InvestigationToolRouter(project_research=service_for(tool_case))) def test_local_normalization_is_bounded_explicit_and_preserves_values(): raw={'update':json.dumps({'confidence':'plausible'}),'next':{'kind':'tool','operation':'project.query', 'arguments':rows(filters=['id','eq',1])}} before=copy.deepcopy(raw);fixed,audit=normalize(raw) assert raw==before and audit['original_document']==raw assert fixed['next']['arguments']['filters']==[{'column':'id','operator':'eq','value':1}] assert fixed['update']=={'confidence':'plausible'} assert len(audit['rules'])==2 assert normalize(fixed)==(fixed,None) @pytest.mark.parametrize('filters',[[],['id','eq'],['id','unknown',1],['id','eq',1,'extra']]) def test_ambiguous_filters_are_not_guessed(filters): raw={'next':{'kind':'tool','operation':'project.query','arguments':rows(filters=filters)}} assert normalize(raw)==(raw,None) def test_fence_is_opt_in_and_duplicate_keys_remain_invalid(): req={'prompt_version':'investigation_bootstrap_v22','output_schema':{MARKER:1}} original='```json\n{"next":{}}\n```' doc,audit=parse_text(original,req) assert doc=={'next':{}} and audit['original_text']==original with pytest.raises(LLMError):parse_text(original,{'output_schema':{}}) with pytest.raises(LLMError):parse_text('```json\n{"a":1,"a":2}\n```',req) doc,audit=parse_text(json.dumps('{"next":{}}'),req) assert doc=={'next':{}} and audit['rules']==['decode_root_object:/'] def test_flat_key_value_filter_pairs_are_unambiguous(): raw={'next':{'kind':'tool','operation':'project.query', 'arguments':rows(filters=['column','id','operator','eq','value',1])}} fixed,audit=normalize(raw) assert fixed['next']['arguments']['filters']==[{'column':'id','operator':'eq','value':1}] assert audit['rules']==['flat_filter_pairs:/next/arguments/filters'] def test_flat_filter_executes_once_with_audit_and_deferred_notice(tool_case): def change(reply,c):reply['next']={'kind':'tool','operation':'project.query','arguments':rows(filters=['id','eq',1])} adapter=LoopAdapter(change);worker=loop(tool_case,adapter) result=asyncio.run(worker.step(*CASE));assert result.status=='PROGRESSED' assert len(adapter.contexts)==1 with PackageReader(result.package) as r: s=state(result);call=json.loads(r.read_bytes(s['hypotheses']['H1']['notes'][-1]['llm_ref'])) assert call['outcome']['normalization']['original_document']['next']['arguments']['filters']==['id','eq',1] assert call['outcome']['document']['next']['arguments']['filters']==[{'column':'id','operator':'eq','value':1}] assert len(BudgetManager(tool_case[0]).snapshot(*CASE).state['accounts']['H1']['actions'])==1 second=asyncio.run(worker.step(*CASE));assert second.status=='PROGRESSED' with PackageReader(second.package) as r: requests=[json.loads(r.read_bytes(n)) for n in r.files if n.startswith('llm/') and n.endswith('/request.json')] assert any('format_notice' in req['messages'][1]['content'] for req in requests) assert PackageValidator().validate(second.package).valid class RepairAdapter(LoopAdapter): def __init__(self,tamper=False):super().__init__();self.requests=[];self.tamper=tamper async def generate(self,config,request,**kwargs): self.requests.append(copy.deepcopy(request)) if request['prompt_version']==REPAIR_PROMPT: replacement={'column':'id','operator':'eq','value':999 if self.tamper else 1} return ProviderResponse({'replacement':replacement},'fixture',100,20) response=await super().generate(config,request,**kwargs) response.document['next']={'kind':'tool','operation':'project.query','arguments':rows(filters=[['id','eq',1]])} return response def test_small_repair_resumes_portably_and_preserves_whole_response(tool_case,tmp_path): adapter=RepairAdapter();worker=loop(tool_case,adapter) first=asyncio.run(worker.step(*CASE));assert first.status=='REJECTED' assert not BudgetManager(tool_case[0]).snapshot(*CASE).state['accounts']['H1']['actions'] with PackageReader(first.package) as r: c=json.loads(r.read_bytes(state(first)['active']['context_ref'])) assert c['response_repair']['path']==['next','arguments','filters',0] portable=CheckpointStore(Workspace(tmp_path/'portable'));portable.intake(first.package) from viano_bug_analysis.tools import InvestigationToolRouter resumed=Investigator(portable,gateway(adapter),router=InvestigationToolRouter(project_research=service_for(tool_case))) second=asyncio.run(resumed.step(*CASE));assert second.status=='PROGRESSED' assert adapter.requests[-1]['prompt_version']==REPAIR_PROMPT sizes=[sum(len(m['content']) for m in req['messages']) for req in adapter.requests] assert sizes[-1]