Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753const std = @import("std");const std_builtin = @import("builtin");const command_admission = @import("../permissions/command_admission.zig");const agent_runtime = @import("../agent/agent_runtime.zig");const agent_stream_provider = @import("../agent/stream_provider.zig");const app_lifecycle = @import("../app/app_lifecycle.zig");const app_runtime_setup = @import("../app/app_runtime_setup.zig");const auth_runtime = @import("../auth/auth_runtime.zig");const credentials = @import("../auth/credentials.zig");const secret = @import("../auth/secret.zig");const oauth_transport = @import("../auth/oauth_transport.zig");const terminal_client_runtime = @import("../terminal/client.zig");const managed_execution = @import("../execution/managed_execution.zig");const context_contract = @import("../workspace/context_contract.zig");const workspace_diagnostics = @import("../workspace/diagnostics.zig");const gateway_provider = @import("../gateway/gateway_provider.zig");const model_catalog = @import("../gateway/model_catalog.zig");const provider_set = @import("../gateway/provider_set.zig");const process_provider = @import("../execution/process_provider.zig");const host = @import("../hosts/host.zig");const pathing = @import("../workspace/pathing.zig");const workspace_access = @import("../workspace/workspace_access.zig");const debug_trace = @import("../shared/debug_trace.zig");const diff_mod = @import("../output/diff.zig");const file_mutation = @import("../tooling/file_mutation.zig");const gateway_error_format = @import("../shared/gateway_error_format.zig");const http_pool = @import("../shared/http_pool.zig");const gateway_client = @import("../../gateway/client.zig");const image_attachments = @import("../images/image_attachments.zig");const hooks = @import("../hooks/hooks.zig");const notification_sound = @import("../notifications/sound.zig");const io_mod = @import("../shared/io.zig");const session_title_generation = @import("../session/session_title_generation.zig");const config_runtime = @import("../config/config_runtime.zig");const model_capabilities = @import("../config/model_capabilities.zig");const model_provider = @import("../config/model_provider.zig");const mcp_elicitation_interaction = @import("../mcp/elicitation_interaction.zig");const mcp_elicitation = @import("../mcp/elicitation.zig");const mcp_access_policy = @import("../mcp/access_policy.zig");const mcp_model_catalog = @import("../mcp/model_catalog.zig");const mcp_runtime = @import("../mcp/mcp_runtime.zig");const mcp_contract = @import("../mcp/mcp_contract.zig");const mode_registry = @import("../modes/mode_registry.zig");const permission_auto_classifier = @import("../permissions/auto_classifier.zig");const prompt_policy = @import("../config/prompt_policy.zig");const auto_classifier_context = @import("../permissions/auto_classifier_context.zig");const permission_gate = @import("../permissions/permission_gate.zig");const permission_request = @import("../permissions/permission_request.zig");const permissions = @import("../permissions/permissions.zig");const session_runtime = @import("../session/session.zig");const command_replay_store = @import("../session/command_replay_store.zig");const session_codec = @import("../session/session_codec.zig");const session_usage = @import("../session/session_usage.zig");const usage_report = @import("../session/usage_report.zig");const session_store = @import("../session/session_store.zig");const legacy_background_migration = @import("../session/legacy_background_migration.zig");const skill_contract = @import("../skills/skill_contract.zig");const skill_runtime = @import("../skills/skill_runtime.zig");const subagent_agent_adapter = @import("../subagent/agent_adapter.zig");const subagent_authority = @import("../subagent/authority.zig");const subagent_domain = @import("../subagent/domain.zig");const subagent_execution = @import("../subagent/execution.zig");const subagent_resume_admission = @import("../subagent/resume_admission.zig");const subagent_tool_host = @import("../subagent/tool_host.zig");const subagent_model_contract = @import("../subagent/model_contract.zig");const text_utils = @import("../shared/text_utils.zig");const test_builtin_gateway = if (std_builtin.is_test) @import("../../builtins/gateway.zig")else struct {};const builtin_tools = @import("../../builtins/tools.zig");const model_tool_schema = @import("../tooling/model_tool_schema.zig");const tool_projection_mod = @import("../tooling/tool_projection.zig");const tool_admission = @import("../tooling/tool_admission.zig");const tool_args = @import("../tooling/tool_args.zig");const tool_dispatch = @import("../tooling/tool_dispatch.zig");const command_output_content = @import("../tooling/command_output_content.zig");const tool_mcp_runtime = @import("../tooling/tool_mcp_runtime.zig");const tool_presentation = @import("../tooling/tool_presentation.zig");const tool_result_errors = @import("../tooling/tool_result_errors.zig");const tool_runtime = @import("../tooling/tool_runtime.zig");const tool_set_contract = @import("../tooling/tool_set.zig");const tool_specs = @import("../tooling/tool_specs.zig");const web_fetch_runtime = @import("../tooling/web_fetch_runtime.zig");const web_search_runtime = @import("../tooling/web_search_runtime.zig");const types = @import("../shared/types.zig");const worker_runtime = @import("../agent/worker_runtime.zig");const ask_presentation = @import("../../ui/ask_presentation.zig");const url_opener = @import("../hosts/url_opener.zig");
const Allocator = std.mem.Allocator;const ChatMessage = types.ChatMessage;const HistoryTurn = types.HistoryTurn;const ImageAttachment = types.ImageAttachment;const PermissionGrant = types.PermissionGrant;const PermissionMode = types.PermissionMode;const SessionRuntime = session_runtime.SessionRuntime;const ToolCall = types.ToolCall;const ToolExecutionResult = agent_runtime.ToolExecutionResult;const ToolPermissionDecision = types.ToolPermissionDecision;const WorkerEvent = worker_runtime.WorkerEvent;const WorkerRuntime = worker_runtime.WorkerRuntime;const McpHasToolFn = tool_mcp_runtime.HasToolFn;
const ShellAction = enum { run, interact, stop,};
const supports_headless_interrupt = switch (std_builtin.os.tag) { .windows, .wasi, .freestanding => false, else => true,};
const HeadlessInterruptInstallError = error{HeadlessInterruptBusy};const headless_interrupt_exit_code: u8 = 130;const headless_termination_exit_code: u8 = 143;
const headless_interrupt = if (supports_headless_interrupt) struct { var coordinator_mutex: std.Io.Mutex = .init; var coordinator_active = false; var cancel_requested = std.atomic.Value(bool).init(false); var requested_signal = std.atomic.Value(u8).init(0); var test_after_reset_before_install: if (std_builtin.is_test) ?*const fn () void else void = if (std_builtin.is_test) null else {}; var test_after_restore: if (std_builtin.is_test) ?*const fn () void else void = if (std_builtin.is_test) null else {};
fn handle(signal: std.posix.SIG) callconv(.c) void { _ = requested_signal.cmpxchgStrong( 0, @intCast(@intFromEnum(signal)), .seq_cst, .seq_cst, ); cancel_requested.store(true, .seq_cst); }
fn exitCode() u8 { return switch (@as(std.posix.SIG, @enumFromInt(requested_signal.load(.seq_cst)))) { std.posix.SIG.TERM => headless_termination_exit_code, else => headless_interrupt_exit_code, }; }
const Scope = struct { old_sigint_action: std.posix.Sigaction = undefined, old_sigterm_action: std.posix.Sigaction = undefined, installed: bool = false,
fn install(enabled: bool) HeadlessInterruptInstallError!Scope { if (!enabled) return .{}; coordinator_mutex.lockUncancelable(io_mod.getIo()); defer coordinator_mutex.unlock(io_mod.getIo()); if (coordinator_active) return error.HeadlessInterruptBusy;
const action: std.posix.Sigaction = .{ .handler = .{ .handler = handle }, .mask = std.posix.sigemptyset(), .flags = 0, }; var scope = Scope{}; requested_signal.store(0, .seq_cst); cancel_requested.store(false, .seq_cst); if (std_builtin.is_test) { if (test_after_reset_before_install) |hook| hook(); } std.posix.sigaction(std.posix.SIG.INT, &action, &scope.old_sigint_action); std.posix.sigaction(std.posix.SIG.TERM, &action, &scope.old_sigterm_action); coordinator_active = true; scope.installed = true; return scope; }
fn restore(self: *Scope, redeliver: bool) void { if (!self.installed) return; coordinator_mutex.lockUncancelable(io_mod.getIo()); defer coordinator_mutex.unlock(io_mod.getIo()); std.debug.assert(coordinator_active); std.posix.sigaction(std.posix.SIG.INT, &self.old_sigint_action, null); std.posix.sigaction(std.posix.SIG.TERM, &self.old_sigterm_action, null); if (std_builtin.is_test) { if (test_after_restore) |hook| hook(); } if (redeliver and cancel_requested.load(.seq_cst)) { const signal: std.posix.SIG = @enumFromInt(requested_signal.load(.seq_cst)); _ = std.c.raise(signal); } coordinator_active = false; self.installed = false; }
fn deinit(self: *Scope) void { self.restore(false); }
fn restoreAndRedeliver(self: *Scope) void { self.restore(true); }
fn requested(self: *const Scope) bool { return self.installed and cancel_requested.load(.seq_cst); } };} else struct { fn exitCode() u8 { return headless_interrupt_exit_code; }
const Scope = struct { fn install(_: bool) HeadlessInterruptInstallError!Scope { return .{}; }
fn deinit(_: *Scope) void {}
fn restoreAndRedeliver(_: *Scope) void {}
fn requested(_: *const Scope) bool { return false; } };};
pub const Config = struct { auth_mode: credentials.AuthMode = .local, command_usage: []const u8, default_model: []const u8, default_agent_step_limit: usize, gateway_retry_count: usize, gateway_chat_url: []const u8, gateway_models_path: []const u8, gateway_provider: gateway_provider.Provider, provider_set: provider_set.Set, process_provider: process_provider.Provider = process_provider.unavailable_provider, secret_store: host.SecretStore, prompt_policy: prompt_policy.Policy, skill_root_policy: skill_contract.RootPolicy, ignored_list_entries: []const []const u8, max_list_entries: usize, max_read_file_bytes: usize, max_read_file_lines: usize, max_read_file_line_len: usize, max_command_output_bytes: usize, max_tool_result_bytes: usize, max_history_turns: usize, mode_registry: mode_registry.Registry, load_mcp_runtime: mcp_runtime.LoadRuntimeFn, context_limit_overrides: []const config_runtime.context_limits.Override = &.{}, additional_directories: []const []const u8 = &.{}, saved_directories_suppressed: bool = false,};
fn runAskChild( raw: ?*anyopaque, turn: *subagent_execution.TurnContext, message: subagent_domain.QueuedMessage, admission: subagent_domain.AdmissionSnapshot, cancel: *std.atomic.Value(bool),) subagent_execution.ServiceError!subagent_execution.RunOutcome { const ctx: *AskContext = @ptrCast(@alignCast(raw.?)); var child_projection = ctx.cfg.mode_registry.buildModelToolProjection( ctx.alloc, ctx.deps.tool_set, ctx.mode_id, .{ .permission_mode = admission.permission_mode, .permission_rules = admission.rules, .subagent_available = true, }, ) catch return error.OutOfMemory; defer child_projection.deinit(ctx.alloc); return subagent_agent_adapter.run(.{ .host = ctx.subagent_host orelse return error.ProviderFailed, .tool_context = ctx.toolContext(), .provider_set = ctx.cfg.provider_set, .system_prompt = ctx.cfg.prompt_policy.system_prompt, .model_prompt_overlay = ctx.cfg.prompt_policy.modelPromptOverlay(admission.model), .skill_catalog = .{ .skills = ctx.loaded_skills.skills, .diagnostics = ctx.loaded_skills.diagnostics }, .advertised_tool_names = child_projection.advertised_names, .advertised_functions = child_projection.advertised_functions, .custom_tool_guidance = child_projection.custom_guidance, .context_registry = ctx.deps.context_registry, .context_enabled = ctx.context_enabled, .project_context = ctx.modelVisibleProjectContext(), .lifecycle_view = ctx.lifecycle_view, }, turn, message, admission, cancel);}
pub const PromptRunResult = struct { exit_code: u8, assistant_output: []u8, final_output: []u8 = &.{}, interrupted: bool = false, model: []u8 = &.{}, session_id: []u8 = &.{}, tool_calls: []ToolCallRecord = &.{}, step_count: usize = 0, error_code: ?[]const u8 = null, auth_failure: ?auth_runtime.FailureSnapshot = null, recovery: ?types.RouteRecoveryStatus = null, recovery_durable: bool = false, usage: types.Usage = .{},
pub fn deinit(self: PromptRunResult, alloc: Allocator) void { alloc.free(self.assistant_output); if (self.final_output.len > 0) alloc.free(self.final_output); if (self.model.len > 0) alloc.free(self.model); if (self.session_id.len > 0) alloc.free(self.session_id); freeToolCallRecords(alloc, self.tool_calls); }};
inline fn failPromptRunResult(err: anytype) @TypeOf(err)!PromptRunResult { return @errorCast(failPromptRunResultDynamic(err));}
noinline fn failPromptRunResultDynamic(err: anyerror) anyerror!PromptRunResult { return err;}
test "prompt result failure writer preserves exact error type and identity" { const failure = failPromptRunResult(error.NoPendingRecovery); try std.testing.expect(@TypeOf(failure) == error{NoPendingRecovery}!PromptRunResult); try std.testing.expectError(error.NoPendingRecovery, failure);}
/// Resume selector parsed from fx ask --resume.const ResumeTarget = session_store.ResumeTarget;
const AskOptions = struct { prompt: []u8, resume_target: ?ResumeTarget = null, permission_override: ?PermissionMode = null, model_override: ?[]u8 = null, effort_override: ?types.ReasoningEffort = null, fast_override: ?bool = null, image_paths: std.ArrayList([]u8) = .empty, images: std.ArrayList(ImageAttachment) = .empty, system_prompt_override: ?[]u8 = null, json_output: bool = false, prompt_permissions: bool = false, timeout_ms: ?usize = null, quiet: bool = false, verbose: bool = false, no_save: bool = false, no_color: bool = false, continue_recovery: bool = false,
fn deinit(self: *AskOptions, alloc: Allocator) void { alloc.free(self.prompt); for (self.image_paths.items) |p| alloc.free(p); self.image_paths.deinit(alloc); for (self.images.items) |image| types.freeImageAttachment(alloc, image); self.images.deinit(alloc); if (self.system_prompt_override) |s| alloc.free(s); if (self.model_override) |m| alloc.free(m); }};
const ToolCallRecord = struct { name: []u8, status: []u8, action: ?ShellAction = null, error_category: ?workspace_diagnostics.ToolCallOutcome = null, error_code: ?[]u8 = null, command_result_json: ?[]u8 = null, ask_question_text: ?[]u8 = null, web_search_completion: ?types.WebSearchCompletion = null, web_fetch_completion: ?types.WebFetchCompletion = null,};
const PendingToolProgress = struct { call_id: []u8, tool_name: []u8, text: ?[]u8 = null, visible: bool = false,
fn deinit(self: PendingToolProgress, alloc: Allocator) void { alloc.free(self.call_id); alloc.free(self.tool_name); if (self.text) |text| alloc.free(text); }};
fn freeToolCallRecord(alloc: Allocator, record: ToolCallRecord) void { alloc.free(record.name); alloc.free(record.status); if (record.error_code) |code| alloc.free(code); if (record.command_result_json) |json| alloc.free(json); if (record.ask_question_text) |text| alloc.free(text);}
fn freeToolCallRecords(alloc: Allocator, records: []ToolCallRecord) void { for (records) |record| freeToolCallRecord(alloc, record); if (records.len > 0) alloc.free(records);}
const WriteFn = *const fn (?*anyopaque, []const u8) anyerror!void;const PermissionApprovalPromptResult = enum { approve, deny, unavailable,};const NotifyAttentionFn = *const fn (?*anyopaque) void;const PermissionApprovalPromptFn = *const fn (?*anyopaque, ?*anyopaque, WriteFn, []const u8, ?*anyopaque, NotifyAttentionFn) anyerror!PermissionApprovalPromptResult;const IsTtyFn = *const fn (?*anyopaque) bool;const LoadStartupStateFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize) anyerror!app_lifecycle.StartupState;const LoadStartupStateWithAuthModeFn = *const fn (Allocator, oauth_transport.Provider, host.SecretStore, []const u8, usize, credentials.AuthMode) anyerror!app_lifecycle.StartupState;const InitializeSessionStoresFn = *const fn (*AskContext) anyerror!void;const LoadSkillsFn = *const fn ( Allocator, []const u8, skill_contract.RootPolicy,) app_runtime_setup.LoadSkillsError!app_runtime_setup.LoadedSkills;const ProcessQueuedPromptFn = *const fn (*agent_runtime.Agent, *const agent_runtime.AgentRuntimeDeps, ?agent_runtime.SemanticPresentationSink, agent_runtime.LifecycleContext, agent_runtime.Config, worker_runtime.QueuedPrompt) anyerror!void;const DiscardPristineSessionFn = *const fn (?*anyopaque, *AskContext, *session_store.LoadedWritableSession) session_store.PristineDiscardDisposition;const PersistYoloAcknowledgmentFn = *const fn (Allocator) config_runtime.CommitAttempt;
const RunDeps = struct { stdin_ctx: ?*anyopaque = null, stdout_ctx: ?*anyopaque = null, stderr_ctx: ?*anyopaque = null, permission_approval_prompt_ctx: ?*anyopaque = null, write_stdout: WriteFn = writeRealStdout, write_stderr: WriteFn = writeRealStderr, permission_approval_prompt: PermissionApprovalPromptFn = promptRealPermissionApproval, stdin_is_tty: IsTtyFn = realStdinIsTty, stdout_is_tty: IsTtyFn = realStdoutIsTty, stderr_is_tty: IsTtyFn = realStderrIsTty, load_startup_state: LoadStartupStateFn = loadStartupStateDefault, load_startup_state_with_auth_mode: LoadStartupStateWithAuthModeFn = app_lifecycle.loadStartupStateWithAuthMode, initialize_session_stores: InitializeSessionStoresFn = initializeSessionStoresDefault, load_skills: LoadSkillsFn = app_runtime_setup.loadSkills, context_registry: context_contract.Registry, tool_set: tool_set_contract.ToolSet, load_mcp_runtime: mcp_runtime.LoadRuntimeFn, process_queued_prompt: ProcessQueuedPromptFn = processQueuedPromptDefault, persist_yolo_acknowledgment: PersistYoloAcknowledgmentFn = persistYoloAcknowledgmentDefault, discard_pristine_session_ctx: ?*anyopaque = null, discard_pristine_session: DiscardPristineSessionFn = discardPristineSessionDefault, install_headless_interrupt: bool = false, stdin_source: StdinSource = .real,};
const OutputMode = enum { quiet, json, raw, terminal, terminal_no_color,
fn isTerminal(self: OutputMode) bool { return self == .terminal or self == .terminal_no_color; }
fn capturesJson(self: OutputMode) bool { return self == .json; }};
const RunOptions = struct { output_mode: OutputMode, prompt_permissions: bool = false, images: []const ImageAttachment = &.{}, command_timeout_ms: ?usize = null, save_session: bool = true, resume_target: ?ResumeTarget = null, color_enabled: bool = true, continue_recovery: bool = false, model_override: ?[]const u8 = null, effort_override: ?types.ReasoningEffort = null, fast_override: ?bool = null, deps: RunDeps,};
fn buildAskGatewayToolProjection( alloc: Allocator, registry: mode_registry.Registry, tool_set: tool_set_contract.ToolSet, mode_id: []const u8, options: tool_projection_mod.Options, has_child_capability: bool,) !tool_projection_mod.EffectiveToolProjection { if (has_child_capability) { return registry.buildModelToolProjection( alloc, tool_set, mode_id, options, ); }
const shell_index = for (tool_set.registry.tools, 0..) |tool, index| { if (tool.executor_kind == .terminal and std.mem.eql(u8, tool.name, "shell")) { break index; } } else { return registry.buildModelToolProjection( alloc, tool_set, mode_id, options, ); };
const projected_tools = try alloc.dupe( tool_dispatch.Tool, tool_set.registry.tools, ); defer alloc.free(projected_tools); projected_tools[shell_index] = builtin_tools.shellProcessOnlySpec(); return registry.buildModelToolProjection( alloc, .{ .registry = .{ .tools = projected_tools }, .order = tool_set.order, .read_only_tool_names = tool_set.read_only_tool_names, }, mode_id, options, );}
const StdinSource = union(enum) { real, tty, bytes: []const u8, read_error,};
const stdin_prompt_resource_byte_limit: usize = 8 * 1024 * 1024;
const AskContext = struct { alloc: Allocator, cfg: Config, deps: RunDeps, workspace_root: []const u8, workspace_access: workspace_access.WorkspaceAccess = .{}, api_key: []const u8 = "", gateway_team: ?[]const u8 = null, credential_source: ?types.CredentialSource = null, account_id: ?[]const u8 = null, refreshed_credential: ?credentials.Credential = null, provider: model_provider.ProviderId = .gateway, model_catalog_access: credentials.CatalogAccess = .{ .public_only = .no_credential }, model: []const u8 = "", /// Resolved review-model override borrowed from the startup state for the /// duration of the run. Empty keeps the reviewer's compiled default. reviewer_model: []const u8 = "", agent_step_limit: usize = 0, max_tool_result_bytes: usize = 64 * 1024, context_limits: config_runtime.context_limits.Values = .{}, fast_mode: bool = false, effort: types.ReasoningEffort = .auto, first_call_tool_choice: types.ToolChoice = .auto, permission_mode: PermissionMode = .ask, permission_rules: types.PermissionRuleSet = .{}, mode_id: []const u8, auto_classifier: permission_auto_classifier.Classifier = permission_auto_classifier.Classifier.disabled(), worker: WorkerRuntime = .{}, use_process_interrupt_flag: bool = false, terminal_client: terminal_client_runtime.Runtime = .{}, managed_executions: managed_execution.Runtime, ephemeral_command_replay: command_replay_store.EphemeralStore, subagent_host: ?*subagent_tool_host.Runtime = null, loaded_skills: app_runtime_setup.LoadedSkills = .{}, store: ?session_store.Store = null, writable: ?session_store.LoadedWritableSession = null, session_write_mutex: std.Io.Mutex = .init, requested_resume: ?ResumeTarget = null, seed_model: []const u8 = "", command_timeout_ms: ?usize = null, session: SessionRuntime, skills_dir: []u8 = &.{}, context_snapshot: context_contract.GatheredContextSnapshot = .{}, context_enabled: bool = true, mcp: ?*mcp_runtime.McpRuntime = null, mcp_elicitation_capabilities: @import("../mcp/elicitation.zig").Capabilities = .{}, failed: bool = false, typed_error_code: ?[]const u8 = null, auth_failure: ?auth_runtime.FailureSnapshot = null, output_mode: OutputMode = .raw, prompt_permissions: bool = false, presenter: ?*ask_presentation.Runtime = null, pending_tool_progress: std.ArrayList(PendingToolProgress) = .empty, deferred_tool_progress: std.ArrayList([]u8) = .empty, pending_tool_progress_mutex: std.Io.Mutex = .init, raw_boundary_pending: bool = false, response_output_start: usize = 0, response_restart_pending: bool = false, raw_trailing_newlines: u8 = 0, raw_has_output: bool = false, command_output_line_open: bool = false, assistant_output: std.ArrayList(u8) = .empty, final_output: std.ArrayList(u8) = .empty, tool_call_records: std.ArrayList(ToolCallRecord) = .empty, tool_call_records_mutex: std.Io.Mutex = .init, web_search_progress_mutex: std.Io.Mutex = .init, step_count: usize = 0, web_fetch_runtime: web_fetch_runtime.Runtime = web_fetch_runtime.Runtime.init(.{}), web_search_runtime: web_search_runtime.Runtime, capability_resolver: gateway_provider.CapabilityResolver = .{}, lifecycle_runtime: hooks.Runtime, lifecycle_view: hooks.RuntimeView, active_turn_id: u64 = 0, notification_player: ?notification_sound.Player = null, image_snapshot_temp_dir: ?[]u8 = null, prompt_snapshot_committed: bool = false, last_recovery_status: ?types.RouteRecoveryStatus = null,
fn init(alloc: Allocator, cfg: Config, deps: RunDeps, workspace_root: []const u8) AskContext { const lifecycle_runtime = hooks.Runtime.init(alloc); return .{ .alloc = alloc, .cfg = cfg, .deps = deps, .workspace_root = workspace_root, .model = cfg.default_model, .seed_model = cfg.default_model, .mode_id = cfg.mode_registry.default_mode_id, .session = session_runtime.SessionRuntime.initWithProviders( cfg.max_history_turns, cfg.provider_set.deferredUsageProviders(), ), .web_search_runtime = web_search_runtime.Runtime.init(.{ .provider = cfg.provider_set.gateway.fx_search.?, }), .terminal_client = terminal_client_runtime.Runtime.init( cfg.process_provider, ), .managed_executions = managed_execution.Runtime.init(alloc), .ephemeral_command_replay = command_replay_store.EphemeralStore.init(alloc), .lifecycle_runtime = lifecycle_runtime, .lifecycle_view = hooks.RuntimeView.empty(), }; }
fn configureNotifications( self: *AskContext, turn_end: bool, attention_required: bool, ) !void { self.notification_player = notification_sound.Player.init(.{ .ctx = self, .emit = emitAskNotificationBell, }); if (turn_end) { try self.lifecycle_runtime.registerPostTurnEnd(.{ .name = "fx.sound.turn_end", .ctx = self, .run = postTurnEndNotification, }); } if (attention_required) { try self.lifecycle_runtime.registerAttentionRequired(.{ .name = "fx.sound.attention_required", .ctx = self, .run = attentionRequiredNotification, }); } self.lifecycle_view = self.lifecycle_runtime.freeze(); }
fn dispatchAttentionRequired(self: *AskContext, kind: hooks.AttentionKind) void { agent_runtime.dispatchAttentionRequiredCheckpoint(self.lifecycleContext(), .{ .turn_id = self.active_turn_id, .kind = kind, }); }
fn postTurnEndNotification( raw: *anyopaque, input: hooks.PostTurnEndInput, ) hooks.HandlerError!void { const self: *AskContext = @ptrCast(@alignCast(raw)); if (input.invocation.scope.kind != .ask) return; const cue: notification_sound.Cue = switch (input.outcome) { .completed => .success, .failed => .@"error", .interrupted, .paused => return, }; self.playNotification(.turn_end, cue); }
fn attentionRequiredNotification( raw: *anyopaque, input: hooks.AttentionRequiredInput, ) hooks.HandlerError!void { const self: *AskContext = @ptrCast(@alignCast(raw)); if (input.invocation.scope.kind != .ask) return; self.playNotification(.attention_required, .success); }
fn playNotification( self: *AskContext, kind: notification_sound.Kind, cue: notification_sound.Cue, ) void { const player = if (self.notification_player) |*configured| configured else { debug_trace.logf( "notifications", "notification delivery dropped kind={s} reason=player_not_configured", .{@tagName(kind)}, ); return; }; debug_trace.logf("notifications", "sound play kind={s} cue={s}", .{ @tagName(kind), @tagName(cue) }); switch (kind) { .attention_required => player.playAttention(cue), .turn_end => player.play(cue), } }
fn deinit(self: *AskContext) void { if (self.subagent_host) |subagent_host| subagent_host.deinit(); self.subagent_host = null; self.managed_executions.deinit(); self.terminal_client.deinit(); self.workspace_access.deinit(self.alloc); self.worker.deinit(std.heap.c_allocator); self.session.usage.finishReconciliationBeforeShutdown(); self.session.usage.finishProfilePublicationsBeforeShutdown(); self.session.usage.configurePublicationSink(null); self.session.usage.configureCheckpointSink(null); if (self.writable) |*writable| { if (self.session.usage.isDirty()) { flushAskSessionUsage(self, writable) catch |err| { debug_trace.logf( "session", "failed to flush ask session usage err={s}", .{@errorName(err)}, ); }; } } self.web_fetch_runtime.deinit(self.alloc); self.web_search_runtime.deinit(); if (self.refreshed_credential) |*credential| credential.deinit(self.alloc); self.capability_resolver.deinit(self.alloc); self.lifecycle_runtime.deinit(); if (self.writable) |*writable| writable.deinit(self.alloc); self.writable = null; if (self.store) |*store| store.deinit(self.alloc); self.ephemeral_command_replay.deinit(); self.permission_rules.deinit(self.alloc); self.session.deinit(self.alloc); if (self.skills_dir.len > 0) self.alloc.free(self.skills_dir); self.context_snapshot.deinit(self.alloc); if (self.mcp) |m| { m.retireAndWait(); m.deinit(); self.alloc.destroy(m); } if (self.image_snapshot_temp_dir) |path| { image_attachments.cleanupSnapshotDir(path); self.alloc.free(path); } self.assistant_output.deinit(self.alloc); self.final_output.deinit(self.alloc); for (self.pending_tool_progress.items) |progress| progress.deinit(self.alloc); self.pending_tool_progress.deinit(self.alloc); for (self.deferred_tool_progress.items) |progress| self.alloc.free(progress); self.deferred_tool_progress.deinit(self.alloc); for (self.tool_call_records.items) |record| { freeToolCallRecord(self.alloc, record); } self.tool_call_records.deinit(self.alloc); self.loaded_skills.deinit(self.alloc); }
fn lifecycleContext(self: *AskContext) agent_runtime.LifecycleContext { return .{ .view = self.lifecycle_view, .scope = .{ .kind = .ask, .workspace_root = self.workspace_root, .session_id = if (self.writable) |*writable| writable.active_id else null, }, .outcome_allocator = self.alloc, }; }
fn cancelFlag(self: *AskContext) *std.atomic.Value(bool) { if (comptime supports_headless_interrupt) { if (self.use_process_interrupt_flag) { return &headless_interrupt.cancel_requested; } } return &self.worker.worker_cancel_requested; }
fn checkCancellation(self: *AskContext) !void { if (self.cancelFlag().load(.seq_cst)) return error.Cancelled; }
fn processInterruptRequested(self: *const AskContext) bool { if (comptime supports_headless_interrupt) { return self.use_process_interrupt_flag and headless_interrupt.cancel_requested.load(.seq_cst); } return false; }
fn imageSnapshotStorageDir(self: *AskContext) ![]u8 { const sessions_dir = if (self.store) |*store| store.sessions_dir else null; const session_id = if (self.writable) |*writable| writable.active_id else null; return session_store.imageSnapshotStorageDir( self.alloc, sessions_dir, session_id, &self.image_snapshot_temp_dir, ); }
fn captureImageAttachment(self: *AskContext, attachment: *ImageAttachment) !void { const snapshot_dir = try self.imageSnapshotStorageDir(); defer self.alloc.free(snapshot_dir); try image_attachments.captureImageSnapshotWithBudget( self.alloc, attachment, snapshot_dir, .{ .cancel_flag = self.cancelFlag() }, ); }
fn captureImageAttachments(self: *AskContext, attachments: []ImageAttachment) !void { const snapshot_dir = try self.imageSnapshotStorageDir(); defer self.alloc.free(snapshot_dir); try image_attachments.captureImageSnapshots( self.alloc, attachments, snapshot_dir, .{ .cancel_flag = self.cancelFlag() }, ); }
/// Record whether restored history references shell execution handles this /// process does not own. Registry membership, not the resume itself, /// decides staleness (see session_runtime.detectStaleShellHandles). fn updateStaleShellHandles(self: *AskContext, history: []const session_runtime.HistoryTurn) void { self.session.has_stale_shell_handles = session_runtime.detectStaleShellHandles( self.alloc, history, &self.managed_executions, ) catch |err| blk: { debug_trace.logf( "session", "event=stale_shell_handle_scan outcome=skipped err={s}", .{@errorName(err)}, ); break :blk false; }; }
fn initializeSessionStores(self: *AskContext) !void { var store = session_store.Store.init(self.alloc, self.workspace_root) catch |err| { if (err == error.OutOfMemory or self.requested_resume != null) return err; debug_trace.logf( "session", "event=ask_session_store_unavailable error={s}", .{@errorName(err)}, ); try self.writeStderr("fx ask: warning: session persistence unavailable; error="); try self.writeStderr(@errorName(err)); try self.writeStderr("; continuing without saving\n"); return; }; var store_owned = true; errdefer if (store_owned) store.deinit(self.alloc);
const seed_preferences = session_codec.DurableSessionPreferences{ .provider = self.provider, .model = @constCast(self.seed_model), .effort = self.effort, .fast_mode = self.fast_mode, }; var writable = if (self.requested_resume) |target| try subagent_resume_admission.resumeForExternalPrompt( store, self.alloc, target, self.workspace_root, .{ .seed_preferences = seed_preferences }, ) else blk: { var state = try freshAskState(self, seed_preferences); defer state.deinit(self.alloc); break :blk try store.startWritableSession(self.alloc, state); }; var writable_owned = true; errdefer if (writable_owned) writable.deinit(self.alloc);
if (self.requested_resume != null) { try self.session.restoreWithPermissionState( self.alloc, writable.state.conversation_language, writable.state.history, writable.state.permission_state, ); updateStaleShellHandles(self, writable.state.history); writable.releaseHydrationHistory(self.alloc); if (writable.state.usage) |usage| { try self.session.usage.restore( self.alloc, usage, writable.state.created_at_ms, ); } else { self.session.usage.restoreLegacyWallDuration( writable.state.created_at_ms, ); } }
const session_dir = try session_store.sessionDirPath( self.alloc, store.sessions_dir, writable.active_id, ); defer self.alloc.free(session_dir); self.session.configureWebFetchArtifacts(self.alloc, session_dir);
self.store = store; self.writable = writable; store_owned = false; writable_owned = false;
if (self.requested_resume != null) { const preferences = self.writable.?.state.preferences; self.provider = preferences.provider; self.model = preferences.model; self.effort = preferences.effort; self.fast_mode = preferences.fast_mode; } self.subagent_host = subagent_tool_host.Runtime.create( self.alloc, &self.store.?, self.writable.?.active_id, .{ .context = self, .resolve_fn = resolveAskSubagentAuthority }, .{ .context = self, .run_fn = runAskChild }, ) catch |err| blk: { if (err == error.OutOfMemory) return err; debug_trace.logf( "subagent", "ask subagent host unavailable root_id={s} err={s}", .{ self.writable.?.active_id, @errorName(err) }, ); break :blk null; }; const capability = try self.writable.?.childCapability(); if (legacy_background_migration.migrate( self.alloc, capability, self.cfg.process_provider, )) |migrated| { if (migrated.records_removed != 0 or migrated.logs_removed != 0) { debug_trace.logf( "session", "legacy process migration committed session={s} records={d} logs={d} signaled={d} unavailable={d}", .{ self.writable.?.active_id, migrated.records_removed, migrated.logs_removed, migrated.processes_signaled, migrated.identities_unavailable, }, ); } } else |err| { debug_trace.logf( "session", "legacy process migration deferred session={s} err={s}", .{ self.writable.?.active_id, @errorName(err) }, ); } }
fn toolContext(self: *AskContext) tool_runtime.Context { const provider_capabilities = self.cfg.provider_set.select(self.provider).capabilities; if (provider_capabilities.fx_search) { self.web_search_runtime.configure(.{ .api_key = self.api_key, .credential_source = self.credential_source, .gateway_team = self.gateway_team, .worker_model = self.model, .gateway_retry_count = self.cfg.gateway_retry_count, .gateway_chat_url = self.cfg.gateway_chat_url, .usage = &self.session.usage, .usage_allocator = self.alloc, }); } var tc: tool_runtime.Context = .{ .workspace_root = self.workspace_root, .access_scope = self.workspace_access.scope(self.workspace_root), .ignored_list_entries = self.cfg.ignored_list_entries, .max_list_entries = self.cfg.max_list_entries, .max_read_file_bytes = self.cfg.max_read_file_bytes, .max_read_file_lines = self.cfg.max_read_file_lines, .max_read_file_line_len = self.cfg.max_read_file_line_len, .max_command_output_bytes = self.cfg.max_command_output_bytes, .max_tool_result_bytes = self.max_tool_result_bytes, .api_key = self.api_key, .agent_stream_provider = self.agentStreamProvider(), .gateway_team = self.gateway_team, .credential_source = self.credential_source, .account_id = self.account_id, .provider = self.provider, .provider_capabilities = provider_capabilities, .oauth_transport = self.cfg.gateway_provider.oauth_transport, .secret_store = self.cfg.secret_store, .model = self.model, .reviewer_model = self.reviewer_model, .gateway_retry_count = self.cfg.gateway_retry_count, .gateway_chat_url = self.cfg.gateway_chat_url, .gateway_models_path = self.cfg.gateway_models_path, .agent_step_limit = self.agent_step_limit, .fast_mode = self.fast_mode, .effort = self.effort, .first_call_tool_choice = self.first_call_tool_choice, .permission_mode = self.permission_mode, .permission_grants = &.{}, .permission_rules = self.permission_rules, .tool_registry = self.toolRegistry(), .subagent_host = self.subagent_host, .subagent_caller_id = if (self.writable) |*writable| writable.active_id else null, .auto_classifier = self.admissionAutoClassifier(), .worker = &self.worker, .cancel_flag = self.cancelFlag(), .session = &self.session, .session_allocator = self.alloc, .skills_dir = self.skills_dir, .context_limits = self.context_limits, .context_enabled = self.context_enabled, .context_registry = self.deps.context_registry, .output_chunk_ctx = @ptrCast(self), .on_output_chunk = onCommandOutputChunk, .mcp_progress_ctx = @ptrCast(self), .on_mcp_progress = onMcpProgress, .session_child_capability = if (self.writable) |*writable| writable.childCapability() catch null else null, .ephemeral_command_replay = self.managed_executions.replayStore(), .terminal_client = &self.terminal_client, .managed_executions = &self.managed_executions, .command_timeout_ms = self.command_timeout_ms, .web_fetch_runtime = &self.web_fetch_runtime, .web_fetch_artifact_store = self.session.webFetchArtifactStore(), .web_fetch_artifact_error = self.session.webFetchArtifactError(), .web_fetch_progress_ctx = @ptrCast(self), .on_web_fetch_progress = onWebFetchProgress, .web_search_runtime_ready = false, .web_search_backend = if (provider_capabilities.fx_search) self.web_search_runtime.dispatchBackend() else null, .web_search_progress_ctx = @ptrCast(self), .on_web_search_progress = onWebSearchProgress, .model_capability_resolver = .{ .ctx = @ptrCast(self), .resolve_fn = resolveModelCapabilities, }, .model_override_resolver = .{ .context = @ptrCast(self), .resolve_fn = resolveModelOverride, }, .interactive = false, .lifecycle_view = self.lifecycle_view, .lifecycle_scope = self.lifecycleContext().scope, }; if (self.mcp != null) { tc.mcp_ctx = @ptrCast(self); tc.mcp_has_tool = mcpHasTool; tc.mcp_validate_tool = mcpValidateTool; tc.mcp_call_tool = mcpCallTool; tc.mcp_search_tools = mcpSearchTools; tc.mcp_tool_schema = mcpToolSchemaJson; tc.mcp_snapshot_tool = mcpSnapshotTool; tc.mcp_call_feature = mcpCallFeature; if (self.mcp_elicitation_capabilities.any()) { tc.mcp_input_responder = .{ .context = @ptrCast(self), .capabilities = self.mcp_elicitation_capabilities, .legacy_url_manual_completion = true, .callback = respondToAskMcpInput, }; } } return tc; }
fn modelVisibleProjectContext(self: *const AskContext) []const u8 { if (!self.context_enabled) return ""; return self.context_snapshot.modelVisibleBytes(); }
fn toolRegistry(self: *const AskContext) tool_dispatch.Registry { return self.deps.tool_set.registry; }
fn admissionAutoClassifier(self: *AskContext) permission_auto_classifier.Classifier { if (self.auto_classifier.enabled()) return self.auto_classifier; const provider = self.cfg.provider_set.select(self.provider).permission_reviewer orelse return permission_auto_classifier.Classifier.disabled(); return permission_auto_classifier.Classifier.withProvider(provider, .{ .credential = self.api_key, .credential_source = self.credential_source, .account_id = self.account_id, .tenant = self.gateway_team, .endpoint = self.cfg.gateway_chat_url, .reviewer_model = self.reviewer_model, .cancel_flag = self.cancelFlag(), .usage = &self.session.usage, .usage_allocator = self.alloc, }); }
fn agentStreamProvider(self: *const AskContext) agent_stream_provider.Provider { return self.cfg.provider_set.select(self.provider).agent_stream_or_unavailable(); }
fn writeStdout(self: *AskContext, text: []const u8) !void { try self.deps.write_stdout(self.deps.stdout_ctx, text); }
fn writeStderr(self: *AskContext, text: []const u8) !void { try self.deps.write_stderr(self.deps.stderr_ctx, text); }
fn writeLine(self: *AskContext, text: []const u8) !void { try self.writeStderr(text); try self.writeStderr("\n"); }};
fn freshAskState( ctx: *AskContext, preferences: session_codec.DurableSessionPreferences,) !session_codec.DurableSessionState { const now = io_mod.milliTimestamp(); const id = try session_store.generateSessionId(ctx.alloc); errdefer ctx.alloc.free(id); const origin = try ctx.alloc.dupe(u8, ctx.workspace_root); errdefer ctx.alloc.free(origin); const workspace = try ctx.alloc.dupe(u8, ctx.workspace_root); errdefer ctx.alloc.free(workspace); const owned_preferences = try preferences.dupe(ctx.alloc); errdefer { var value = owned_preferences; value.deinit(ctx.alloc); } const history = try ctx.alloc.alloc(HistoryTurn, 0); errdefer ctx.alloc.free(history); const permission_state = try ctx.session.snapshotPermissionState(ctx.alloc); errdefer { var value = permission_state; value.deinit(ctx.alloc); } return .{ .id = id, .origin_workspace_root = origin, .workspace_root = workspace, .created_at_ms = now, .updated_at_ms = now, .conversation_language = ctx.session.languageSnapshot(), .preferences = owned_preferences, .history = history, .total_input_tokens = 0, .total_output_tokens = 0, .permission_state = permission_state, };}
pub fn run(alloc: Allocator, args: []const [:0]const u8, cfg: Config, context_registry: context_contract.Registry, tool_set: tool_set_contract.ToolSet) !u8 { return runWithDeps(alloc, args, cfg, .{ .load_startup_state = app_lifecycle.loadStartupState, .context_registry = context_registry, .tool_set = tool_set, .load_mcp_runtime = cfg.load_mcp_runtime, .install_headless_interrupt = true, });}
fn selectOutputMode(quiet: bool, json: bool, stdout_is_tty: bool, no_color: bool) OutputMode { if (json) return .json; if (quiet) return .quiet; if (!stdout_is_tty) return .raw; return if (no_color) .terminal_no_color else .terminal;}
fn askElicitationCapabilities( output_mode: OutputMode, stdin_is_tty: bool, stderr_is_tty: bool,) @import("../mcp/elicitation.zig").Capabilities { if (!output_mode.isTerminal() or !stdin_is_tty or !stderr_is_tty) return .{}; return .{ .form = true, .url = true };}
fn checkHeadlessCancellation(deps: RunDeps) !void { if (comptime supports_headless_interrupt) { if (deps.install_headless_interrupt and headless_interrupt.cancel_requested.load(.seq_cst)) { return error.Cancelled; } }}
fn writeAskUsage(deps: RunDeps, usage: []const u8) !void { try deps.write_stderr(deps.stderr_ctx, "usage: fx "); try deps.write_stderr(deps.stderr_ctx, usage); try deps.write_stderr(deps.stderr_ctx, "\n");}
fn askErrorNotice(err: anyerror) ?[]const u8 { if (auth_runtime.preparationFailureNotice(err)) |notice| return notice; return switch (err) { error.ImagePreparationFailed => image_attachments.image_preparation_failed_notice, error.ModelImageCapabilityUnavailable => image_attachments.model_image_capability_unavailable_notice, else => null, };}
fn runWithDeps(alloc: Allocator, args: []const [:0]const u8, cfg: Config, deps: RunDeps) !u8 { var interrupt_scope = try headless_interrupt.Scope.install( deps.install_headless_interrupt, ); defer interrupt_scope.restoreAndRedeliver();
var options = parseOptionsWithStdin(alloc, args, deps.stdin_source) catch |err| switch (err) { error.MissingPrompt => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try deps.write_stderr(deps.stderr_ctx, "fx ask: missing prompt\n"); try writeAskUsage(deps, cfg.command_usage); return 1; }, error.NoSaveResumeConflict => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, "InvalidAskArgs"); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try deps.write_stderr(deps.stderr_ctx, "fx ask: --no-save cannot be used with --resume or --resume-id\n"); try writeAskUsage(deps, cfg.command_usage); return 1; }, error.PromptResourceLimitExceeded => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try deps.write_stderr(deps.stderr_ctx, "fx ask: prompt exceeds the local input safety limit\n"); return 1; }, error.PromptInputReadFailed => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try deps.write_stderr(deps.stderr_ctx, "fx ask: failed to read prompt from stdin\n"); return 1; }, error.InvalidAskArgs => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try writeAskUsage(deps, cfg.command_usage); return 1; }, error.InvalidPromptText => { if (hasJsonFlag(args)) { const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; } try deps.write_stderr(deps.stderr_ctx, "fx ask: prompt must be valid UTF-8 and contain no NUL bytes\n"); return 1; }, else => return err, }; defer options.deinit(alloc);
if (interrupt_scope.requested()) return headless_interrupt.exitCode(); if (options.image_paths.items.len > 0) { const workspace_root = try io_mod.realpathAlloc(alloc, "."); defer alloc.free(workspace_root); if (!try preflightAskImages(alloc, workspace_root, &options, deps)) return 1; } if (interrupt_scope.requested()) return headless_interrupt.exitCode();
var effective_cfg = cfg; if (options.system_prompt_override) |sp| { effective_cfg.prompt_policy.system_prompt = sp; }
const output_mode = selectOutputMode( options.quiet, options.json_output, deps.stdout_is_tty(deps.stdout_ctx), options.no_color, ); const result = runPromptInternal(alloc, options.prompt, options.permission_override, effective_cfg, .{ .output_mode = output_mode, .prompt_permissions = options.prompt_permissions, .images = if (options.images.items.len > 0) options.images.items else &.{}, .command_timeout_ms = options.timeout_ms, .save_session = !options.no_save, .resume_target = options.resume_target, .color_enabled = !options.no_color, .continue_recovery = options.continue_recovery, .model_override = options.model_override, .effort_override = options.effort_override, .fast_override = options.fast_override, .deps = deps, }) catch |err| { if (interrupt_scope.requested()) return headless_interrupt.exitCode(); if (err == error.OutOfMemory) return err; if (err == error.OneOffSessionNotResumable and !options.json_output) { try deps.write_stderr( deps.stderr_ctx, "fx ask: subagent child sessions cannot be resumed directly; message the named agent from its parent session\n", ); return 1; } if (!options.json_output) { const notice = askErrorNotice(err) orelse return err; try deps.write_stderr(deps.stderr_ctx, "fx ask: "); try deps.write_stderr(deps.stderr_ctx, notice); try deps.write_stderr(deps.stderr_ctx, "\n"); return 1; } const json = try renderErrorJsonResult(alloc, @errorName(err)); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); return 1; }; defer result.deinit(alloc);
if (result.interrupted or interrupt_scope.requested()) { return headless_interrupt.exitCode(); }
if (options.json_output) { const json = try renderFinalJsonResult(alloc, result); defer alloc.free(json); if (interrupt_scope.requested()) return headless_interrupt.exitCode(); try deps.write_stdout(deps.stdout_ctx, json); }
return if (interrupt_scope.requested()) headless_interrupt.exitCode() else result.exit_code;}
fn preflightAskImages( alloc: Allocator, workspace_root: []const u8, options: *AskOptions, deps: RunDeps,) !bool { for (options.image_paths.items) |image_path| { const attachment = image_attachments.loadUserImageAttachment( alloc, workspace_root, image_path, ) catch |err| { if (err == error.OutOfMemory) return err; const reason = switch (err) { error.FileNotFound => "image file not found", error.UnsupportedImageType => "unsupported image type", error.ImageTooLarge => image_attachments.image_too_large_notice, else => @errorName(err), }; if (options.json_output) { const detail = try std.fmt.allocPrint(alloc, "{s}: {s}", .{ @errorName(err), image_path }); defer alloc.free(detail); const json = try renderErrorJsonResult(alloc, detail); defer alloc.free(json); try deps.write_stdout(deps.stdout_ctx, json); } else { var message: std.Io.Writer.Allocating = .init(alloc); defer message.deinit(); try message.writer.print("fx ask: failed to attach image \"{s}\": {s}\n", .{ image_path, reason }); try deps.write_stderr(deps.stderr_ctx, message.written()); } return false; }; var attachment_owned = true; errdefer if (attachment_owned) types.freeImageAttachment(alloc, attachment); try options.images.append(alloc, attachment); attachment_owned = false; } return true;}
pub fn runPrompt(alloc: Allocator, prompt: []const u8, auto_permission: bool, cfg: Config, context_registry: context_contract.Registry, tool_set: tool_set_contract.ToolSet) !u8 { const result = try runPromptInternal(alloc, prompt, if (auto_permission) .auto else null, cfg, .{ .output_mode = .raw, .images = &.{}, .deps = .{ .context_registry = context_registry, .tool_set = tool_set, .load_mcp_runtime = cfg.load_mcp_runtime, }, }); defer result.deinit(alloc); return result.exit_code;}
pub fn runPromptCapture(alloc: Allocator, prompt: []const u8, auto_permission: bool, cfg: Config, context_registry: context_contract.Registry, tool_set: tool_set_contract.ToolSet) !PromptRunResult { return runPromptInternal(alloc, prompt, if (auto_permission) .auto else null, cfg, .{ .output_mode = .json, .images = &.{}, .deps = .{ .context_registry = context_registry, .tool_set = tool_set, .load_mcp_runtime = cfg.load_mcp_runtime, }, });}
fn missingCredentialResult( alloc: Allocator, options: RunOptions, provider: model_provider.ProviderId, preferred: ?credentials.Source,) !PromptRunResult { const status = auth_runtime.StatusSnapshot{ .required_source = auth_runtime.requestedSource(provider, preferred), }; const message = status.missingHelp(.cli).?; try options.deps.write_stderr(options.deps.stderr_ctx, "fx ask: "); try options.deps.write_stderr(options.deps.stderr_ctx, message); try options.deps.write_stderr(options.deps.stderr_ctx, "\n"); return .{ .exit_code = 1, .assistant_output = try alloc.dupe(u8, ""), .error_code = "MissingCredentials", };}
fn runPromptInternal(alloc: Allocator, prompt: []const u8, permission_override: ?PermissionMode, initial_cfg: Config, options: RunOptions) !PromptRunResult { var cfg = initial_cfg; var owned_prompt = try alloc.dupe(u8, prompt); defer alloc.free(owned_prompt);
try checkHeadlessCancellation(options.deps); var startup = if (cfg.auth_mode == .host_managed) try options.deps.load_startup_state_with_auth_mode( alloc, cfg.gateway_provider.oauth_transport, cfg.secret_store, cfg.default_model, cfg.default_agent_step_limit, cfg.auth_mode, ) else try options.deps.load_startup_state( alloc, cfg.gateway_provider.oauth_transport, cfg.secret_store, cfg.default_model, cfg.default_agent_step_limit, ); defer startup.deinit(alloc); cfg.provider_set.definitions = startup.configured_providers.definitions; // Bind gateway chat traffic to a per-process connection pool and warm one // connection in the background while the rest of startup continues. var gateway_pool: ?*http_pool.HttpPool = null; defer if (gateway_pool) |pool| { if (pool.deinit() == .destroyed) alloc.destroy(pool); }; if (io_mod.getenv("FX_BENCH") == null and startup.provider == .gateway) { if (alloc.create(http_pool.HttpPool)) |pool| { pool.* = http_pool.HttpPool.init(alloc); gateway_pool = pool; if (cfg.provider_set.gateway.agent_stream) |stream| { var stamped = stream; stamped.context = pool; cfg.provider_set.gateway.agent_stream = stamped; } pool.warmAsync(gateway_client.resolveChatUrlForWarmup(cfg.gateway_chat_url)); } else |_| {} } try checkHeadlessCancellation(options.deps);
var permission_mode = toCorePermissionMode(startup.permission_mode); const mode_id: []const u8 = cfg.mode_registry.default_mode_id; if (permission_override) |explicit| permission_mode = explicit;
if (permission_mode == .yolo and !startup.yolo_acknowledged) { try emitHeadlessYoloWarning(alloc, options); }
for (startup.config_diagnostics) |diagnostic| { var notice_writer: std.Io.Writer.Allocating = .init(alloc); defer notice_writer.deinit(); try notice_writer.writer.print( "fx ask: config {s}: {s}", .{ @tagName(diagnostic.layer), @tagName(diagnostic.cause) }, ); try config_runtime.writeDiagnosticMetadata(¬ice_writer.writer, diagnostic); try notice_writer.writer.writeByte('\n'); const notice = try notice_writer.toOwnedSlice(); defer alloc.free(notice); try options.deps.write_stderr(options.deps.stderr_ctx, notice); }
try app_lifecycle.applyWorkspaceLaunch( &startup, alloc, cfg.additional_directories, cfg.saved_directories_suppressed, ); try checkHeadlessCancellation(options.deps);
if (cfg.auth_mode == .local and !options.continue_recovery and options.resume_target == null and startup.credential == null) { if (startup.credential_load_failure) |failure| { if (auth_runtime.preparationError(auth_runtime.classifyCredentialFailure(failure.source, failure.err))) |err| return err; } return missingCredentialResult(alloc, options, startup.provider, startup.credential_source_preference); }
var owned_resumed_model: ?[]u8 = null; defer if (owned_resumed_model) |model| alloc.free(model); var ctx = AskContext.init(alloc, cfg, options.deps, startup.workspace_root); defer ctx.deinit(); if (options.save_session) { _ = try ctx.session.initializeProfileUsage(alloc, io_mod.getenv("HOME")); ctx.session.attachProfileUsagePublisher(alloc); } ctx.use_process_interrupt_flag = options.deps.install_headless_interrupt; try ctx.checkCancellation(); var presenter: ?ask_presentation.Runtime = null; defer if (presenter) |*value| value.deinit(); var worker_events_drained = false; ctx.workspace_access = startup.takeWorkspaceAccess(); ctx.output_mode = options.output_mode; ctx.prompt_permissions = options.prompt_permissions; ctx.mcp_elicitation_capabilities = askElicitationCapabilities( options.output_mode, options.deps.stdin_is_tty(options.deps.stdin_ctx), options.deps.stderr_is_tty(options.deps.stderr_ctx), ); ctx.command_timeout_ms = options.command_timeout_ms; ctx.model = startup.selected_model; ctx.provider = startup.provider; ctx.seed_model = startup.configured_model; ctx.requested_resume = options.resume_target; ctx.agent_step_limit = startup.agent_step_limit; ctx.max_tool_result_bytes = startup.max_tool_result_bytes; ctx.context_limits = startup.context_limits; ctx.context_limits.applyCommandLine(cfg.context_limit_overrides); ctx.fast_mode = startup.fast_mode; ctx.effort = toCoreReasoningEffort(startup.effort); ctx.first_call_tool_choice = startup.first_call_tool_choice; ctx.permission_mode = permission_mode; ctx.reviewer_model = startup.review_model; ctx.mode_id = mode_id; ctx.permission_rules = try takeCorePermissionRules(alloc, &startup); ctx.context_enabled = startup.context_enabled; if (options.output_mode.isTerminal()) { presenter = try ask_presentation.Runtime.init(alloc, .{ .text = owned_prompt, .images = @constCast(options.images), }, options.output_mode == .terminal_no_color); ctx.presenter = &presenter.?; } try ctx.configureNotifications( startup.notification_turn_end, startup.notification_attention_required, ); ctx.session.setConversationLanguageFromUserMessage(owned_prompt); if (options.save_session) { try ctx.checkCancellation(); try options.deps.initialize_session_stores(&ctx); try ctx.checkCancellation(); if (config_runtime.providerEnvOverride() != null) { ctx.provider = startup.provider; ctx.model = startup.selected_model; } if (startup.model_source == .process_override) { ctx.model = startup.selected_model; } else if (ctx.requested_resume != null) { owned_resumed_model = try alloc.dupe(u8, ctx.model); ctx.model = owned_resumed_model.?; } ctx.session.setConversationLanguageFromUserMessage(owned_prompt); }
// Ask flags are per-run overrides: they win over startup and resumed // session preferences but are never persisted into session preferences. if (options.model_override) |model| { ctx.model = model; } if (options.effort_override) |effort| { ctx.effort = effort; } if (options.fast_override) |fast| { ctx.fast_mode = fast; } else if (options.model_override != null and ctx.requested_resume == null and startup.fast_mode_source == .compiled_default) { // Default fast mode applies to the compiled default model only; an // explicit model override drops it unless --fast restores it. ctx.fast_mode = false; }
var recovery_checkpoint: ?session_codec.RecoveryCheckpoint = null; defer if (recovery_checkpoint) |*checkpoint| checkpoint.deinit(alloc); if (options.continue_recovery) { const writable = if (ctx.writable) |*value| value else return failPromptRunResult(error.RecoverySessionUnavailable); const checkpoint = writable.state.recovery_checkpoint orelse return failPromptRunResult(error.NoPendingRecovery); recovery_checkpoint = try checkpoint.dupe(alloc); alloc.free(owned_prompt); owned_prompt = try alloc.dupe(u8, recovery_checkpoint.?.user.text); ctx.session.setConversationLanguageFromUserMessage(owned_prompt); } else if (ctx.writable) |*writable| { if (writable.conversation_writer.turn_open) { const checkpoint = writable.state.recovery_checkpoint orelse return error.InvalidRecoveryCheckpoint; const prompt_snapshot_committed = ctx.prompt_snapshot_committed; defer ctx.prompt_snapshot_committed = prompt_snapshot_committed; try propagateHistoryTurn(&ctx, checkpoint.interruptedTurn()); } }
var routed_credential: ?credentials.Credential = null; defer if (routed_credential) |*credential| credential.deinit(alloc); if (cfg.auth_mode == .host_managed) { ctx.api_key = ""; ctx.gateway_team = null; ctx.credential_source = .host_managed; ctx.account_id = null; ctx.model_catalog_access = .host_managed; if (comptime @import("builtin").os.tag != .wasi) { if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { ctx.session.usage.replaceHostManagedReconciliationAuthority( ctx.alloc, ctx.provider, ); } } } else { const startup_matches_final_model = if (startup.credential) |credential| startup.provider.same_authority(ctx.provider) and model_provider.authorizesCredential(ctx.provider, credential.source) else false; const startup_credential_is_final = startup_matches_final_model and !credentials.sourceRefreshable(startup.credential.?.source); const credential: *const credentials.Credential = if (startup_credential_is_final) &startup.credential.? else routed: { const preferred_source = if (ctx.provider == .gateway) startup.credential_source_preference else null; routed_credential = try auth_runtime.prepareCredential( alloc, cfg.gateway_provider.oauth_transport, cfg.secret_store, ctx.provider, preferred_source, ); if (routed_credential == null) { return missingCredentialResult(alloc, options, ctx.provider, preferred_source); } break :routed &routed_credential.?; }; ctx.api_key = credential.token; ctx.gateway_team = credential.gatewayTeam(); ctx.credential_source = credential.source; ctx.account_id = credential.accountId(); ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( credential.source, credential.token, credential.gatewayTeam(), credential.accountId(), ); if (comptime @import("builtin").os.tag != .wasi) { if (ctx.cfg.provider_set.select(ctx.provider).deferred_usage != null) { ctx.session.usage.replaceProviderReconciliationCredential( alloc, ctx.provider, credential.source, credential.accountId(), credential.token, ); } } }
const restored_image_catalog = try ctx.session.snapshotImageCatalog(alloc, &.{}); defer types.freeImageAttachmentSlice(alloc, restored_image_catalog); const restored_image_bounds = try image_attachments.calculate_next_image_id(restored_image_catalog);
const current_images = try types.dupeImageAttachmentSlice( alloc, if (recovery_checkpoint) |checkpoint| checkpoint.user.images else options.images, ); defer types.freeImageAttachmentSlice(alloc, current_images); defer if (recovery_checkpoint == null and options.save_session and !ctx.prompt_snapshot_committed) { image_attachments.deleteUnreferencedImageSnapshots(current_images, restored_image_catalog); }; if (recovery_checkpoint == null and current_images.len > 0) { try ctx.checkCancellation(); _ = std.math.add( usize, restored_image_bounds.next_id, current_images.len - 1, ) catch return failPromptRunResult(error.ImageIdOverflow); for (current_images, 0..) |*image, index| image.id = restored_image_bounds.next_id + index; try ctx.captureImageAttachments(current_images); try ctx.checkCancellation(); }
const authorized_image_catalog = if (recovery_checkpoint) |checkpoint| try session_runtime.merge_image_catalog_history_turn(alloc, restored_image_catalog, checkpoint.interruptedTurn()) else try ctx.session.snapshotImageCatalog(alloc, current_images); defer types.freeImageAttachmentSlice(alloc, authorized_image_catalog);
try ctx.checkCancellation(); ctx.loaded_skills = try options.deps.load_skills( alloc, startup.workspace_root, cfg.skill_root_policy, ); const loaded_skills = &ctx.loaded_skills; try ctx.checkCancellation(); skill_runtime.traceDiagnostics("ask_startup", loaded_skills.diagnostics); ctx.skills_dir = loaded_skills.dir; loaded_skills.dir = &.{}; if (startup.context_enabled) { try ctx.checkCancellation(); const context_targets = try context_contract.applicableTargetsForImages(alloc, current_images); defer if (context_targets.len > 0) alloc.free(context_targets); ctx.context_snapshot = try ctx.deps.context_registry.gatherDefaultSnapshot(alloc, .{ .workspace_root = startup.workspace_root, .access_scope = ctx.workspace_access.scope(ctx.workspace_root), .targets = context_targets, .context_limits = ctx.context_limits, }); try ctx.checkCancellation(); for (ctx.context_snapshot.notices) |notice| try pushContextNotice(@ptrCast(&ctx), notice); }
try ctx.checkCancellation(); ctx.mcp = try options.deps.load_mcp_runtime( alloc, startup.workspace_root, ctx.mcp_elicitation_capabilities, ); if (ctx.mcp) |mcp| { var health_snapshot = try mcp.snapshotHealth( alloc, @intCast(@max(io_mod.milliTimestamp(), 0)), ); defer health_snapshot.deinit(alloc); for (health_snapshot.configuration_issues) |issue| { try ctx.writeStderr("fx ask: "); try ctx.writeStderr(issue.message); try ctx.writeStderr("\n"); } const project_names = try mcp.pendingWorkspaceNames(alloc); defer mcp_contract.freeOwnedStrings(alloc, project_names); if (project_names.len > 0) { try ctx.writeStderr("fx ask: skipped unapproved project MCP servers: "); for (project_names, 0..) |name, index| { if (index > 0) try ctx.writeStderr(", "); try ctx.writeStderr(name); } try ctx.writeStderr(". Approve with fx mcp trust approve <name> before retrying.\n"); } if (options.output_mode.isTerminal()) { mcp.connectAllCancellable(ctx.toolRegistry(), ctx.cancelFlag()); } else { mcp.connectRequiredForAsk(ctx.toolRegistry(), ctx.cancelFlag()); } } try ctx.checkCancellation(); if (ctx.mcp) |mcp| { if (try mcp.requiredStartupFailure( alloc, @intCast(@max(io_mod.milliTimestamp(), 0)), )) |failure| { defer alloc.free(failure); try ctx.writeStderr("fx ask: "); try ctx.writeStderr(failure); try ctx.writeStderr("\n"); return failPromptRunResult(error.McpRequiredServerUnavailable); } } const session_child_capability = if (ctx.writable) |*writable| writable.childCapability() catch null else null; var tool_projection = try buildAskGatewayToolProjection(alloc, ctx.cfg.mode_registry, options.deps.tool_set, ctx.mode_id, .{ .permission_mode = ctx.permission_mode, .permission_rules = ctx.permission_rules, .subagent_available = ctx.subagent_host != null, }, session_child_capability != null); defer tool_projection.deinit(alloc);
const context_history = try ctx.session.snapshotHistory(alloc); defer types.freeHistoryTurnSlice(alloc, context_history); const root_user_intent_context = try auto_classifier_context.buildCanonicalRootUserContext( alloc, owned_prompt, ctx.session.agent.history.items, ); defer alloc.free(root_user_intent_context); ctx.active_turn_id = if (recovery_checkpoint) |checkpoint| checkpoint.turn_id else debug_trace.nextTurnId();
const job: worker_runtime.QueuedPrompt = .{ .turn_id = ctx.active_turn_id, .prompt = owned_prompt, .images = current_images, .authorized_image_catalog = authorized_image_catalog, .model = @constCast(ctx.model), .api_key = @constCast(ctx.api_key), .gateway_team = if (ctx.gateway_team) |team| @constCast(team) else null, .credential_source = ctx.credential_source, .account_id = if (ctx.account_id) |account_id| @constCast(account_id) else null, .provider = ctx.provider, .permission_mode = ctx.permission_mode, .history = context_history, .unversioned_history_count = ctx.session.unversionedHistoryEnd(), .root_user_intent_context = root_user_intent_context, .grants = &.{}, // process_queued_prompt is synchronous here; AskContext keeps the // immutable snapshot allocations alive for the whole call. .context_snapshot = ctx.context_snapshot, .recovery_checkpoint = recovery_checkpoint, };
const deps = agentRuntimeDeps(&ctx); const semantic_presentation = if (ctx.presenter) |value| value.semanticSink() else null; try ctx.checkCancellation(); const title_task = maybeStartAskTitleTask( &ctx, startup.session_title_generation, owned_prompt, recovery_checkpoint == null and options.save_session and ctx.requested_resume == null, ); defer if (title_task) |task| completeAskTitleTask(&ctx, task); const current_prompt_is_root_authority = if (ctx.writable) |writable| writable.external_prompt_origin == .persistent_child and recovery_checkpoint == null else false; options.deps.process_queued_prompt(&ctx.session.agent, &deps, semantic_presentation, ctx.lifecycleContext(), .{ .system_prompt = cfg.prompt_policy.system_prompt, .model_prompt_overlay = cfg.prompt_policy.modelPromptOverlay(ctx.model), .skill_catalog = .{ .skills = loaded_skills.skills, .diagnostics = loaded_skills.diagnostics }, .gateway_retry_count = cfg.gateway_retry_count, .gateway_chat_url = cfg.gateway_chat_url, .advertised_tool_names = tool_projection.advertised_names, .advertised_functions = tool_projection.advertised_functions, .provider_capabilities = cfg.provider_set.select(ctx.provider).capabilities, .custom_tool_guidance = tool_projection.custom_guidance, .agent_step_limit = startup.agent_step_limit, .max_tool_result_bytes = startup.max_tool_result_bytes, .cancel_flag = ctx.cancelFlag(), .fast_mode = ctx.fast_mode, .effort = ctx.effort, .first_call_tool_choice = ctx.first_call_tool_choice, .workspace_root = ctx.workspace_root, .access_scope = ctx.workspace_access.scope(ctx.workspace_root), .origin = if (ctx.writable) |writable| if (writable.external_prompt_origin == .persistent_child) .subagent else .root else .root, .root_user_messages = if (ctx.writable) |writable| writable.external_root_user_messages else &.{}, .root_user_evidence_complete = if (ctx.writable) |writable| writable.external_root_user_evidence_complete else false, .current_prompt_is_root_authority = current_prompt_is_root_authority, .context_limits = ctx.context_limits, .session_child_capability = session_child_capability, .ephemeral_command_replay = if (session_child_capability == null) &ctx.ephemeral_command_replay else null, }, job) catch |err| switch (err) { error.NonInteractivePermissionRequired => { const assistant_output = try alloc.dupe(u8, ctx.assistant_output.items); errdefer alloc.free(assistant_output); const tool_calls = try takeToolCallRecords(&ctx, alloc); return .{ .exit_code = 1, .assistant_output = assistant_output, .interrupted = ctx.processInterruptRequested(), .tool_calls = tool_calls, .error_code = "NonInteractivePermissionRequired", .usage = ctx.session.agent.turn_usage, }; }, else => { if (!options.output_mode.capturesJson()) return err; var failed_result = try takePromptRunResult(&ctx, alloc); failed_result.exit_code = 1; failed_result.error_code = @errorName(err); return failed_result; }, }; worker_events_drained = true; if (ctx.presenter) |value| try value.finish();
var result = try takePromptRunResult(&ctx, alloc); finalizeFreshAuthSession(&ctx, &result); return result;}
/// Starts background title generation for a fresh saved session. The task runs/// concurrently with the first turn and is applied by `completeAskTitleTask`/// on every exit path. The locally derived title remains when generation is/// skipped or unavailable.fn maybeStartAskTitleTask( ctx: *AskContext, setting_enabled: bool, prompt: []const u8, fresh_session: bool,) ?*session_title_generation.Task { // Unit tests share the real provider bundles; never spawn network side // calls from a test process. Wiring is covered by e2e mock servers. if (comptime @import("builtin").is_test) return null; if (comptime @import("builtin").os.tag == .wasi) return null; if (!setting_enabled or !fresh_session) return null; if (ctx.session.agent.history.items.len != 0) return null; const writable = if (ctx.writable) |*value| value else return null; const bundle = ctx.cfg.provider_set.select(ctx.provider); const title_model = bundle.title_model orelse return null; const agent_stream = bundle.agent_stream orelse return null; const excerpt = session_title_generation.promptExcerpt(prompt) orelse return null; if (ctx.credential_source != .host_managed and ctx.api_key.len == 0) return null; const task = session_title_generation.Task.create(.{ .session_id = writable.active_id, .model = title_model, .prompt_excerpt = excerpt, .api_key = if (ctx.api_key.len > 0) ctx.api_key else null, .gateway_team = ctx.gateway_team, .account_id = ctx.account_id, .credential_source = ctx.credential_source, .stream_provider = agent_stream, }) catch return null; task.spawn() catch |err| { debug_trace.logf("session", "event=title_generation result=unavailable reason=spawn err={s}", .{@errorName(err)}); task.destroy(); return null; }; return task;}
/// Joins the bounded title task and installs the generated title unless the/// session already carries a user-set title.fn completeAskTitleTask(ctx: *AskContext, task: *session_title_generation.Task) void { defer task.destroy(); task.join(); const title = task.takeTitle() orelse return; defer std.heap.c_allocator.free(title); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else return; if (!std.mem.eql(u8, writable.active_id, task.session_id)) { debug_trace.logf("session", "event=title_generation_apply result=dropped reason=session_changed session={s}", .{task.session_id}); return; } const installed = session_title_generation.installGeneratedTitle(ctx.alloc, writable, ctx.session.agent.history.items, title) catch |err| { debug_trace.logf("session", "event=title_generation_apply result=failed session={s} err={s}", .{ task.session_id, @errorName(err) }); return; }; if (installed) { debug_trace.logf("session", "event=title_generation_apply result=installed session={s}", .{task.session_id}); }}
fn takePromptRunResult(ctx: *AskContext, alloc: Allocator) !PromptRunResult { const assistant_output = try alloc.dupe(u8, ctx.assistant_output.items); errdefer alloc.free(assistant_output); const final_output: []u8 = if (ctx.final_output.items.len > 0) try alloc.dupe(u8, ctx.final_output.items) else @constCast(&.{}); errdefer if (final_output.len > 0) alloc.free(final_output); const model = try alloc.dupe(u8, ctx.model); errdefer alloc.free(model); const session_id = if (ctx.writable) |writable| try alloc.dupe(u8, writable.active_id) else try alloc.dupe(u8, ""); errdefer if (session_id.len > 0) alloc.free(session_id);
const tool_calls = try takeToolCallRecords(ctx, alloc);
return .{ .exit_code = if (ctx.failed) 1 else 0, .assistant_output = assistant_output, .final_output = final_output, .interrupted = ctx.processInterruptRequested(), .model = model, .session_id = session_id, .tool_calls = tool_calls, .step_count = ctx.step_count, .error_code = ctx.typed_error_code, .auth_failure = ctx.auth_failure, .recovery = ctx.last_recovery_status, .recovery_durable = ctx.writable != null, .usage = ctx.session.agent.turn_usage, };}
fn takeToolCallRecords(ctx: *AskContext, alloc: Allocator) ![]ToolCallRecord { ctx.tool_call_records_mutex.lockUncancelable(io_mod.getIo()); defer ctx.tool_call_records_mutex.unlock(io_mod.getIo()); const owned = if (ctx.tool_call_records.items.len > 0) try alloc.dupe(ToolCallRecord, ctx.tool_call_records.items) else @as([]ToolCallRecord, &.{}); if (owned.len > 0) ctx.tool_call_records.clearRetainingCapacity(); return owned;}
fn finalizeFreshAuthSession(ctx: *AskContext, result: *PromptRunResult) void { ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); if (result.auth_failure == null or ctx.requested_resume != null or ctx.session.historyLen() != 0 or result.step_count != 0 or ctx.store == null or ctx.writable == null) { return; }
if (ctx.writable.?.state.recovery_checkpoint != null) { _ = ctx.writable.?.appendEvent( ctx.alloc, .{ .recovery_checkpoint_cleared = .{} }, io_mod.milliTimestamp(), ) catch |err| { debug_trace.logf( "ask", "fresh auth session retained because recovery checkpoint clear failed err={s}", .{@errorName(err)}, ); return; }; }
ctx.session.clearWebFetchArtifacts(); if (ctx.subagent_host) |subagent_host| subagent_host.deinit(); ctx.subagent_host = null;
var loaded = ctx.writable.?; ctx.writable = null; const disposition = ctx.deps.discard_pristine_session( ctx.deps.discard_pristine_session_ctx, ctx, &loaded, ); if (disposition != .discarded) return; if (result.session_id.len > 0) ctx.alloc.free(result.session_id); result.session_id = &.{};}
fn agentRuntimeDeps(ctx: *AskContext) agent_runtime.AgentRuntimeDeps { ctx.session.usage.configureCheckpointSink( if (ctx.writable != null) .{ .context = @ptrCast(ctx), .allocator = ctx.alloc, .persist = persistUsageCheckpoint, } else null, ); return .{ .ctx = @ptrCast(ctx), .agent_stream_provider = ctx.agentStreamProvider(), .render_assistant_text = ctx.output_mode.isTerminal(), .tool_registry = ctx.toolRegistry(), .context_registry = ctx.deps.context_registry, .context_enabled = ctx.context_enabled, .finalize_turn = finalizeTurn, .release_agent_terminal_lease = releaseAgentTerminalLease, .append_runtime_context = appendRuntimeContext, .append_static_context = appendStaticContext, .validate_tool_call = validateToolCall, .snapshot_mcp_definition = snapshotMcpDefinition, .prepare_skill_call = prepareSkillCall, .check_tool_availability = checkToolAvailability, .request_tool_permission = requestToolPermissionOutcomeWithRequest, .request_prepared_file_mutation_permission = requestPreparedFileMutationPermissionOutcomeForRuntime, .resolve_tool_action_display_target = resolveToolActionDisplayTarget, .describe_tool_action = describeToolAction, .describe_tool_action_completed = describeToolActionCompleted, .describe_tool_action_denied = describeToolActionDenied, .permission_target_for_call = permissionTargetForCall, .execute_tool_call = executeToolCallAuthorized, .publish_committed_file_handoff = publishCommittedFileHandoff, .propagate_history_turn = propagateHistoryTurn, .commit_context_compaction = .{ .commit = commitContextCompaction }, .recovery_checkpoint = if (ctx.writable != null) .{ .set = setRecoveryCheckpoint, .clear = clearRecoveryCheckpoint, } else null, .propagate_grant = propagateGrant, .push_event = pushEvent, .push_text = pushText, .push_tool_lifecycle = pushToolLifecycle, .push_diff_block = pushDiffBlock, .push_system_notice = pushSystemNotice, .push_context_notice = pushContextNotice, .push_route_recovery_status = pushRouteRecoveryStatus, .push_command_output_complete = pushCommandOutputComplete, .push_http_error = pushHttpError, .refresh_gateway_credential = refreshGatewayCredential, .available_model_capabilities = availableModelCapabilities, .resolve_model_capabilities = resolveModelCapabilities, .model_catalog_unavailable = modelCatalogUnavailable, .format_tool_execution_error = formatToolExecutionError, .record_tool_call_rejected = recordToolCallRejected, .record_tool_call_failed = recordToolCallFailed, .report_usage = reportUsage, .usage = &ctx.session.usage, .usage_allocator = ctx.alloc, };}
fn modelCatalogUnavailable(raw_ctx: *anyopaque) bool { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return ctx.capability_resolver.state == .failed;}
fn releaseAgentTerminalLease(raw_ctx: *anyopaque, session_id: []const u8) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_runtime.release_agent_terminal_lease(ctx.toolContext(), session_id);}
fn refreshGatewayCredential( raw_ctx: *anyopaque, alloc: Allocator, source: credentials.Source, mode: auth_runtime.CredentialRefreshMode, expected_account_id: ?[]const u8,) !?[]u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (mode == .if_needed and auth_runtime.requestPathCredentialVerifiedRecently(source)) { debug_trace.logf("auth", "credential refresh skipped source={t} reason=verified_recently", .{source}); return null; } var refreshed = (try auth_runtime.refreshCredentialForAccount( ctx.cfg.gateway_provider.oauth_transport, ctx.alloc, source, mode, expected_account_id, )) orelse return null; defer refreshed.deinit(ctx.alloc); return try adoptRefreshedAskCredential(ctx, alloc, &refreshed);}
fn adoptRefreshedAskCredential( ctx: *AskContext, worker_alloc: Allocator, refreshed: *credentials.Credential,) ![]u8 { if (ctx.credential_source != refreshed.source) return error.CredentialAuthorityChanged; if (!optionalCredentialFieldEqual(ctx.account_id, refreshed.accountId()) or !optionalCredentialFieldEqual(ctx.gateway_team, refreshed.gatewayTeam())) { return error.CredentialAuthorityChanged; }
const worker_token = try worker_alloc.dupe(u8, refreshed.token); errdefer secret.zeroAndFree(worker_alloc, worker_token); if (ctx.refreshed_credential) |*current| current.deinit(ctx.alloc); ctx.refreshed_credential = refreshed.*; refreshed.token = &.{}; refreshed.account_id = null; refreshed.team_id = null; refreshed.team_slug = null;
const current = &ctx.refreshed_credential.?; ctx.api_key = current.token; ctx.credential_source = current.source; ctx.account_id = current.accountId(); ctx.gateway_team = current.gatewayTeam(); ctx.model_catalog_access = credentials.catalogAccessForCredentialAndAccount( current.source, current.token, current.gatewayTeam(), current.accountId(), ); return worker_token;}
fn optionalCredentialFieldEqual(left: ?[]const u8, right: ?[]const u8) bool { if (left == null or right == null) return left == null and right == null; return std.mem.eql(u8, left.?, right.?);}
fn persistUsageCheckpoint( raw_ctx: *anyopaque, snapshot: session_usage.Snapshot,) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else return error.SessionPersistenceUnavailable; const store = ctx.store orelse return error.SessionPersistenceUnavailable; const recovery_checkpoint = try store.prepareUsageRecoveryCheckpoint( ctx.alloc, writable, snapshot, ); _ = try writable.appendEvent( ctx.alloc, .{ .usage_checkpointed = .{ .usage = snapshot } }, recovery_checkpoint.timestamp_ms, ); try store.finishUsageRecoveryCheckpoint( writable.active_id, recovery_checkpoint, );}
/// Overwrite session totals with the latest completion usage (same semantics as/// interactive `agentReportUsage`). Gateway `input_tokens` is full-prompt/// occupancy, not a delta, so overwrite rather than accumulate.fn reportUsage(raw_ctx: *anyopaque, usage: types.Usage) void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else return; if (usage.input_tokens) |input| writable.state.total_input_tokens = input; if (usage.output_tokens) |output| writable.state.total_output_tokens = output;}
fn resolveModelCapabilities(raw_ctx: *anyopaque, _: Allocator, model: []const u8) model_capabilities.ResolveError!model_capabilities.Capabilities { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const bundle = ctx.cfg.provider_set.select(ctx.provider); const catalog = bundle.model_catalog orelse return bundle.fallbackModelCapabilities(model); return ctx.capability_resolver.resolve( ctx.alloc, catalog, .{ .access = ctx.model_catalog_access, .endpoint = ctx.cfg.gateway_models_path, .cancel_flag = ctx.cancelFlag(), }, model, bundle.fallbackModelCapabilities(model), );}
fn availableModelCapabilities(raw_ctx: *anyopaque, model: []const u8) model_capabilities.Capabilities { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return ctx.capability_resolver.available( model, ctx.cfg.provider_set.select(ctx.provider).fallbackModelCapabilities(model), );}
/// Subagent model overrides resolve against the same catalog the capability/// path uses. The resolve loads the catalog on first use; only cancellation/// or an unavailable catalog falls back to raw passthrough.fn resolveModelOverride(raw_ctx: ?*anyopaque, alloc: Allocator, raw_model: []const u8) Allocator.Error!subagent_model_contract.ModelCatalogMatch { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx.?)); const bundle = ctx.cfg.provider_set.select(ctx.provider); const catalog_provider = bundle.model_catalog orelse return .no_catalog; _ = ctx.capability_resolver.resolve( ctx.alloc, catalog_provider, .{ .access = ctx.model_catalog_access, .endpoint = ctx.cfg.gateway_models_path, .cancel_flag = ctx.cancelFlag(), }, raw_model, bundle.fallbackModelCapabilities(raw_model), ) catch return .no_catalog; const entries = ctx.capability_resolver.catalogEntries() orelse return .no_catalog; var ids = try model_catalog.projectModelIds(alloc, entries); defer { for (ids.items) |id| alloc.free(id); ids.deinit(alloc); } return subagent_model_contract.matchCatalogModel(alloc, ids.items, raw_model);}
fn finalizeTurn(raw_ctx: *anyopaque, turn_id: u64, outcome: types.TurnPresentationOutcome, disposition: ?types.ProviderCompletionDisposition) !void { std.debug.assert(turn_id != 0); _ = disposition; const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (outcome == .failed or outcome == .paused) { ctx.failed = true; } if (outcome == .completed) discard_restarted_json_preview(ctx);}
fn appendRuntimeContext(raw_ctx: *anyopaque, arena: Allocator, messages: *std.ArrayList(ChatMessage)) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); try ctx.deps.context_registry.appendDefaultTransient(.{ .workspace_root = ctx.workspace_root, .access_scope = ctx.workspace_access.scope(ctx.workspace_root), .interactive = false, .permission_mode = ctx.permission_mode, .stale_shell_handles = ctx.session.has_stale_shell_handles, }, arena, messages);}
fn appendStaticContext(raw_ctx: *anyopaque, arena: Allocator, project_context: ?[]const u8, messages: *std.ArrayList(ChatMessage)) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); try ctx.deps.context_registry.appendDefaultStatic(.{ .project_context = project_context orelse ctx.modelVisibleProjectContext(), }, arena, messages); var snapshot = if (ctx.mcp) |mcp| try mcp.snapshotModelCatalog(arena, ctx.permission_rules, true) else try mcp_model_catalog.Snapshot.empty(arena); defer snapshot.deinit(arena); const section = try mcp_model_catalog.render(arena, snapshot); if (section.text.len > 0) { try messages.append(arena, .{ .role = .system, .content = section.text }); } if (section.notice) |notice| try pushContextNotice(raw_ctx, notice);}
fn snapshotMcpDefinition(raw_ctx: *anyopaque, arena: Allocator, name: []const u8, known: tool_mcp_runtime.Binding) !tool_mcp_runtime.DefinitionSnapshot { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_runtime.snapshotMcpDefinition(ctx.toolContext(), arena, name, known);}
fn validateToolCall(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall) !agent_runtime.ToolCallValidationResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (try ctx.cfg.mode_registry.toolPolicyDeniedJson(arena, ctx.deps.tool_set, ctx.mode_id, call.name)) |reason| { return .{ .failure = reason }; } return tool_runtime.validateToolCall(ctx.toolContext(), arena, call);}
fn checkToolAvailability(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall) !?[]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_runtime.checkToolAvailability(ctx.toolContext(), arena, call);}
fn prepareSkillCall(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, locations: ?*const skill_contract.Locations) !skill_contract.CallPreparation { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_runtime.prepareSkillCall(ctx.toolContext(), arena, call, locations);}
fn cliAdmissionContext( ctx: *AskContext, advertised_dynamic_tool_names: []const []const u8, review_turn: ?permission_auto_classifier.ReviewTurnContext,) tool_runtime.Context { var tool_ctx = tool_runtime.withAdvertisedDynamicToolNames( ctx.toolContext(), advertised_dynamic_tool_names, ); tool_ctx.permission_review_turn = review_turn; if (cliContextPermissionPromptAllowed(ctx)) { tool_ctx.permission_prompter = .{ .context = @ptrCast(ctx), .request_fn = requestCliPermission, }; } return tool_ctx;}
fn requestPreparedFileMutationPermissionOutcomeForRuntime(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, prepared: *tool_admission.PreparedFileMutationCall, review_turn: permission_auto_classifier.ReviewTurnContext, permission_mode: PermissionMode, local_grants: []const PermissionGrant, live_authority: ?agent_runtime.LiveToolAuthority, advertised_dynamic_tool_names: []const []const u8) !command_admission.PermissionOutcome { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const tool_ctx = cliAdmissionContext(ctx, advertised_dynamic_tool_names, review_turn); return finishCliPermissionOutcome( ctx, tool_ctx, arena, call, permission_mode, try tool_admission.requestPreparedFileMutationPermissionOutcome( tool_ctx.admissionInputWithLiveAuthority(live_authority), arena, call, prepared, permission_mode, local_grants, ), );}
fn requestToolPermissionOutcome(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, permission_mode: PermissionMode, local_grants: []const PermissionGrant, advertised_dynamic_tool_names: []const []const u8) !command_admission.PermissionOutcome { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const tool_ctx = cliAdmissionContext(ctx, advertised_dynamic_tool_names, null); return finishCliPermissionOutcome( ctx, tool_ctx, arena, call, permission_mode, try tool_admission.requestPermissionOutcome( tool_ctx.admissionInput(), arena, call, permission_mode, local_grants, ), );}
fn requestToolPermissionOutcomeWithRequest(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, review_turn: permission_auto_classifier.ReviewTurnContext, permission_mode: PermissionMode, local_grants: []const PermissionGrant, live_authority: ?agent_runtime.LiveToolAuthority, revalidation: ?agent_runtime.LivePermissionRevalidation, advertised_dynamic_tool_names: []const []const u8, mcp_review_schema_json: ?[]const u8) !command_admission.PermissionOutcome { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); var tool_ctx = cliAdmissionContext(ctx, advertised_dynamic_tool_names, review_turn); tool_ctx.mcp_review_schema_json = mcp_review_schema_json; return finishCliPermissionOutcome( ctx, tool_ctx, arena, call, permission_mode, if (revalidation) |request| switch (request) { .action => |action| try tool_admission.revalidateLiveActionPermissionOutcome( tool_ctx.admissionInputWithLiveAuthority(live_authority), arena, call, permission_mode, local_grants, action.authority, action.human_approval, ), } else try tool_admission.requestPermissionOutcome( tool_ctx.admissionInputWithLiveAuthority(live_authority), arena, call, permission_mode, local_grants, ), );}
inline fn failPermissionOutcome(err: anytype) @TypeOf(err)!command_admission.PermissionOutcome { return @errorCast(failPermissionOutcomeDynamic(err));}
noinline fn failPermissionOutcomeDynamic(err: anyerror) anyerror!command_admission.PermissionOutcome { return err;}
test "permission outcome failure writer preserves exact error type and identity" { const failure = failPermissionOutcome(error.NonInteractivePermissionRequired); try std.testing.expect( @TypeOf(failure) == error{NonInteractivePermissionRequired}!command_admission.PermissionOutcome, ); try std.testing.expectError(error.NonInteractivePermissionRequired, failure);}
fn finishCliPermissionOutcome( ctx: *AskContext, tool_ctx: tool_runtime.Context, arena: Allocator, call: ToolCall, permission_mode: PermissionMode, outcome: command_admission.PermissionOutcome,) !command_admission.PermissionOutcome { if (outcome.decision != .permission_required) return outcome; const label = try tool_presentation.formatPlainAction(arena, .{ .tool_registry = tool_ctx.tool_registry, .call = call, .workspace_root = tool_ctx.workspace_root, }); switch (outcome.requirement orelse .approval_required) { .configured_rule => try writeBlockedActionGuidance( ctx, "fx ask: permission required by configured rule", label, "noninteractive_permission_prompt_unavailable", "fx ask: rerun in the interactive shell to approve this action, or add a narrow matching permission rule before retrying\n", ), .approval_required => try writeBlockedActionGuidance( ctx, "fx ask: permission required for tool execution in noninteractive mode", label, "noninteractive_permission_prompt_unavailable", if (permission_mode == .auto) "fx ask: human approval is required for this action; use the interactive shell to approve it, or add a narrow matching permission rule\n" else "fx ask: rerun with --auto to review this exact action automatically, or use the interactive shell to approve it\n", ), } try recordToolCallRejected( @ptrCast(ctx), arena, call, "Permission required", null, ); return failPermissionOutcome(error.NonInteractivePermissionRequired);}
const TestReviewTurn = struct { tool_calls: [1]ToolCall, root_messages: [1][]const u8,
fn init(root_text: []const u8, call: ToolCall) TestReviewTurn { return .{ .tool_calls = .{call}, .root_messages = .{root_text}, }; }
fn context(self: *const TestReviewTurn) permission_auto_classifier.ReviewTurnContext { return .{ .model = "openai/gpt-5", .pending_assistant = .{ .role = .assistant, .tool_calls = &self.tool_calls }, .target_call_id = self.tool_calls[0].id, .origin = .root, .trusted_root_context = self.root_messages[0], }; }};
fn writeBlockedActionGuidance( ctx: *AskContext, headline: []const u8, label: []const u8, reason: []const u8, hint: []const u8,) !void { try ctx.writeLine(headline); try ctx.writeStderr("fx ask: blocked action: "); try ctx.writeStderr(label); try ctx.writeStderr("\nfx ask: reason="); try ctx.writeStderr(reason); try ctx.writeStderr("\n"); try ctx.writeStderr(hint);}
fn requestCliPermission( raw_ctx: *anyopaque, alloc: Allocator, request: permission_request.PermissionRequest, _: ToolCall, _: ?*const diff_mod.FileReview, _: ?[]const PermissionGrant,) anyerror!permission_request.OwnedPermissionResponse { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return switch (try promptCliPermissionApproval(ctx, request.label)) { .approve => permission_request.OwnedPermissionResponse.init(alloc, .once, null), .deny => permission_request.OwnedPermissionResponse.init(alloc, .deny, null), .unavailable => error.PermissionPromptUnavailable, };}
fn promptCliPermissionApproval( ctx: *AskContext, label: []const u8,) !PermissionApprovalPromptResult { if (!cliContextPermissionPromptAllowed(ctx)) return .unavailable; return try ctx.deps.permission_approval_prompt( ctx.deps.permission_approval_prompt_ctx, ctx.deps.stderr_ctx, ctx.deps.write_stderr, label, ctx, notifyAskPermissionAttention, );}
fn notifyAskPermissionAttention(raw: ?*anyopaque) void { const ctx: *AskContext = @ptrCast(@alignCast(raw.?)); ctx.dispatchAttentionRequired(.permission);}
fn emitAskNotificationBell(raw: *anyopaque) void { const ctx: *AskContext = @ptrCast(@alignCast(raw)); if (!ctx.deps.stderr_is_tty(ctx.deps.stderr_ctx)) return; ctx.writeStderr("\x07") catch |err| { debug_trace.logf( "notifications", "fx ask terminal bell write failed err={s}", .{@errorName(err)}, ); };}
fn cliPermissionPromptAllowed( output_mode: OutputMode, prompt_permissions: bool, stdin_is_tty: bool,) bool { return switch (output_mode) { .raw, .terminal, .terminal_no_color => true, .json, .quiet => prompt_permissions and stdin_is_tty, };}
fn cliContextPermissionPromptAllowed(ctx: *const AskContext) bool { return cliPermissionPromptAllowed( ctx.output_mode, ctx.prompt_permissions, ctx.deps.stdin_is_tty(ctx.deps.stdin_ctx), );}
fn describeToolAction(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, display_target: ?[]const u8, advertised_dynamic_tool_names: []const []const u8) ![]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_presentation.formatPlainAction(arena, .{ .tool_registry = ctx.toolRegistry(), .call = call, .workspace_root = ctx.workspace_root, .display_target = display_target, .is_available_dynamic_mcp_tool = lifecycleDynamicMcpToolAvailable(ctx, call.name, advertised_dynamic_tool_names), });}
fn resolveToolActionDisplayTarget(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall) !?[]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); return tool_presentation.resolveTerminalDisplayTarget( arena, ctx.toolRegistry(), ctx.workspace_root, &ctx.terminal_client, &ctx.managed_executions, call, );}
fn describeToolActionCompleted(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, display_target: ?[]const u8, advertised_dynamic_tool_names: []const []const u8) ![]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (try tool_presentation.formatSubagentPlainAction(arena, call, .completed)) |line| return line; return tool_presentation.formatPlainAction(arena, .{ .tool_registry = ctx.toolRegistry(), .call = call, .workspace_root = ctx.workspace_root, .display_target = display_target, .is_available_dynamic_mcp_tool = lifecycleDynamicMcpToolAvailable(ctx, call.name, advertised_dynamic_tool_names), });}
fn describeToolActionDenied(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, display_target: ?[]const u8, label: []const u8, advertised_dynamic_tool_names: []const []const u8) ![]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (try tool_presentation.formatSubagentPlainAction(arena, call, .{ .stopped = label })) |line| return line; const action = try tool_presentation.formatPlainAction(arena, .{ .tool_registry = ctx.toolRegistry(), .call = call, .workspace_root = ctx.workspace_root, .display_target = display_target, .is_available_dynamic_mcp_tool = lifecycleDynamicMcpToolAvailable(ctx, call.name, advertised_dynamic_tool_names), }); return std.fmt.allocPrint(arena, "{s}: {s}", .{ label, action });}
fn lifecycleDynamicMcpToolAvailable(ctx: *AskContext, name: []const u8, advertised_dynamic_tool_names: []const []const u8) bool { const capability = lifecycleDynamicMcpAvailability(ctx); return dynamicMcpToolAvailable(ctx.toolRegistry(), name, advertised_dynamic_tool_names, capability.context, capability.has_tool, capability.access);}
fn lifecycleDynamicMcpAvailability(ctx: *AskContext) tool_mcp_runtime.RuntimeCapabilities { if (ctx.mcp == null) return .{}; return .{ .context = @ptrCast(ctx), .has_tool = mcpHasTool };}
fn dynamicMcpToolAvailable(registry: tool_dispatch.Registry, name: []const u8, advertised_dynamic_tool_names: []const []const u8, mcp_ctx: ?*anyopaque, has_tool: ?McpHasToolFn, access: tool_mcp_runtime.Access) bool { if (!tool_presentation.isAdvertisedDynamicMcpName(registry, name, advertised_dynamic_tool_names)) return false; const raw_ctx = mcp_ctx orelse return false; const has = has_tool orelse return false; return has(raw_ctx, name, access);}
fn permissionTargetForCall(raw_ctx: *anyopaque, arena: Allocator, call: ToolCall, advertised_dynamic_tool_names: []const []const u8) ![]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const tool_ctx = tool_runtime.withAdvertisedDynamicToolNames( ctx.toolContext(), advertised_dynamic_tool_names, ); return tool_admission.permissionTargetForCall(tool_ctx.admissionInput(), arena, call);}
fn executeToolCallAuthorized( raw_ctx: *anyopaque, request: agent_runtime.ToolExecutionRequest,) !ToolExecutionResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); var tool_ctx = ctx.toolContext(); tool_ctx.root_user_intent_context = request.root_user_intent_context; tool_ctx.root_user_messages = request.root_user_messages; tool_ctx.root_user_evidence_complete = request.root_user_evidence_complete; tool_ctx.session_grants = request.session_grants; tool_ctx.advertised_dynamic_tool_names = request.advertised_dynamic_tool_names; tool_ctx.max_tool_result_bytes = request.max_tool_result_bytes; const result = tool_runtime.executeToolCallAuthorized( tool_ctx, request, ) catch |err| { captureToolExecutionError(ctx, request, err); return err; }; captureToolExecutionResult(ctx, request, result); return result;}
fn captureToolExecutionError( ctx: *AskContext, request: agent_runtime.ToolExecutionRequest, err: anyerror,) void { if (ctx.output_mode.capturesJson()) { appendToolCallRecordBestEffort( ctx, request.call, "error", null, .runtime_failed, @errorName(err), ); }}
fn captureToolExecutionResult( ctx: *AskContext, request: agent_runtime.ToolExecutionRequest, result: ToolExecutionResult,) void { if (ctx.output_mode.capturesJson()) { appendToolCallRecordBestEffort( ctx, request.call, if (result.status == .failure) "error" else "success", result, if (result.status == .failure) if (result.command_result_json != null) .command_failed else .tool_failed else null, null, ); } if (result.status == .failure and result.finish_turn) { ctx.failed = true; ctx.typed_error_code = result.status_detail; }}
fn publishCommittedFileHandoff( _: *anyopaque, _: file_mutation.CommittedFileHandoff,) agent_runtime.SecondaryPublicationReport { return .{ .diff = .skipped, .tracker = .skipped };}
fn recordToolCallRejected( raw_ctx: *anyopaque, _: Allocator, call: ToolCall, model_output: []const u8, command_result_json: ?[]const u8,) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (!ctx.output_mode.capturesJson()) return; appendToolCallRecordBestEffort( ctx, call, "error", .{ .status = .failure, .model_output = model_output, .command_result_json = command_result_json, }, .rejected, "rejected", );}
fn recordToolCallFailed( raw_ctx: *anyopaque, _: Allocator, call: ToolCall, model_output: []const u8, command_result_json: ?[]const u8,) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (!ctx.output_mode.capturesJson()) return; appendToolCallRecordBestEffort( ctx, call, "error", .{ .status = .failure, .model_output = model_output, .command_result_json = command_result_json, }, .tool_failed, "tool_failed", );}
fn appendToolCallRecordBestEffort( ctx: *AskContext, call: ToolCall, status: []const u8, result: ?ToolExecutionResult, error_category: ?workspace_diagnostics.ToolCallOutcome, error_code: ?[]const u8,) void { appendToolCallRecord( ctx, call, status, result, error_category, error_code, ) catch |err| { debug_trace.logf( "cli_ask", "tool-call capture dropped name={s} status={s} err={s}", .{ call.name, status, @errorName(err) }, ); };}
fn appendToolCallRecord( ctx: *AskContext, call: ToolCall, status: []const u8, result: ?ToolExecutionResult, error_category: ?workspace_diagnostics.ToolCallOutcome, explicit_error_code: ?[]const u8,) !void { ctx.tool_call_records_mutex.lockUncancelable(io_mod.getIo()); defer ctx.tool_call_records_mutex.unlock(io_mod.getIo());
const name = try ctx.alloc.dupe(u8, call.name); errdefer ctx.alloc.free(name); const owned_status = try ctx.alloc.dupe(u8, status); errdefer ctx.alloc.free(owned_status); var scratch_state = std.heap.ArenaAllocator.init(ctx.alloc); defer scratch_state.deinit(); const scratch = scratch_state.allocator(); const is_shell = std.mem.eql(u8, call.name, "shell"); const action = if (is_shell and error_category != null) shell_action_for_call(scratch, call) else null; const error_code = if (is_shell and error_category != null) try ctx.alloc.dupe( u8, explicit_error_code orelse shell_failure_code( scratch, error_category.?, result, ), ) else null; errdefer if (error_code) |code| ctx.alloc.free(code); const command_result_json = if (result) |execution| if (execution.command_result_json) |json| try ctx.alloc.dupe(u8, json) else null else null; errdefer if (command_result_json) |json| ctx.alloc.free(json); const ask_question_text = try questionTextForAskCall(ctx.alloc, call); errdefer if (ask_question_text) |text| ctx.alloc.free(text);
try ctx.tool_call_records.append(ctx.alloc, .{ .name = name, .status = owned_status, .action = action, .error_category = if (is_shell) error_category else null, .error_code = error_code, .command_result_json = command_result_json, .ask_question_text = ask_question_text, .web_search_completion = if (result) |execution| execution.web_search_completion else null, .web_fetch_completion = if (result) |execution| execution.web_fetch_completion else null, });}
fn shell_action_for_call(arena: Allocator, call: ToolCall) ?ShellAction { if (!std.mem.eql(u8, call.name, "shell")) return null; const parsed = std.json.parseFromSliceLeaky( std.json.Value, arena, call.arguments_json, .{}, ) catch return null; const root = switch (parsed) { .object => |object| object, else => return null, }; const request = if (root.get("request")) |value| switch (value) { .object => |object| object, else => return null, } else root; const action = switch (request.get("action") orelse return null) { .string => |value| value, else => return null, }; return std.meta.stringToEnum(ShellAction, action);}
fn shell_failure_code( arena: Allocator, category: workspace_diagnostics.ToolCallOutcome, result: ?ToolExecutionResult,) []const u8 { return switch (category) { .succeeded => "tool_failed", .rejected => "rejected", .runtime_failed => "runtime_failed", .command_failed => command_failure_code(arena, result), .tool_failed => shell_tool_failure_code(arena, result), };}
fn command_failure_code( arena: Allocator, result: ?ToolExecutionResult,) []const u8 { const json = (result orelse return "command_failed").command_result_json orelse return "command_failed"; const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{}) catch return "command_failed"; const object = switch (parsed) { .object => |value| value, else => return "command_failed", }; if (json_bool(object, "termination_indeterminate")) return "termination_indeterminate"; if (json_bool(object, "output_incomplete")) return "output_incomplete"; if (json_bool(object, "timed_out")) return "timeout"; if (json_has_value(object, "signal")) return "signal"; if (json_nonzero_int(object, "exit_code")) return "nonzero_exit"; return "command_failed";}
fn shell_tool_failure_code( arena: Allocator, result: ?ToolExecutionResult,) []const u8 { const body = (result orelse return "tool_failed").model_output; const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch return "tool_failed"; const root = switch (parsed) { .object => |value| value, else => return "tool_failed", }; const failure = switch (root.get("error") orelse return "tool_failed") { .object => |value| value, else => return "tool_failed", }; const tool = switch (failure.get("tool") orelse return "tool_failed") { .string => |value| value, else => return "tool_failed", }; if (!std.mem.eql(u8, tool, "shell")) return "tool_failed"; const code = switch (failure.get("code") orelse return "tool_failed") { .string => |value| value, else => return "tool_failed", }; if (!safe_error_code(code)) return "tool_failed"; return code;}
fn safe_error_code(code: []const u8) bool { if (code.len == 0 or code.len > 64) return false; for (code) |byte| { if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '-') return false; } return true;}
fn json_bool(object: std.json.ObjectMap, name: []const u8) bool { return switch (object.get(name) orelse return false) { .bool => |value| value, else => false, };}
fn json_has_value(object: std.json.ObjectMap, name: []const u8) bool { return switch (object.get(name) orelse return false) { .null => false, else => true, };}
fn json_nonzero_int(object: std.json.ObjectMap, name: []const u8) bool { return switch (object.get(name) orelse return false) { .integer => |value| value != 0, else => false, };}
fn questionTextForAskCall(alloc: Allocator, call: ToolCall) !?[]u8 { if (!std.mem.eql(u8, call.name, "ask_user_question")) return null; var parsed = std.json.parseFromSlice( std.json.Value, alloc, call.arguments_json, .{}, ) catch |err| switch (err) { error.OutOfMemory => return err, else => return null, }; defer parsed.deinit(); if (parsed.value != .object) return null; const questions_value = parsed.value.object.get("questions") orelse return null; if (questions_value != .array or questions_value.array.items.len == 0) return null; const first = questions_value.array.items[0]; if (first != .object) return null; const question_value = first.object.get("question") orelse return null; if (question_value != .string) return null; const text = std.mem.trim(u8, question_value.string, " \t\r\n"); if (text.len == 0) return null; const max_question_text_bytes: usize = 256; return try alloc.dupe( u8, text_utils.utf8PrefixByBytes(text, max_question_text_bytes), );}
fn propagateHistoryTurn(raw_ctx: *anyopaque, turn: HistoryTurn) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); var prepared = try ctx.session.prepareHistoryEntry(ctx.alloc, turn); var prepared_owned = true; defer if (prepared_owned) types.freeHistoryTurn(ctx.alloc, prepared); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else { ctx.session.commitPreparedHistoryEntry(ctx.alloc, prepared); prepared_owned = false; return; }; try writable.prepareHistoryTurnForCommit(ctx.alloc, &prepared); _ = writable.appendEvent( ctx.alloc, .{ .history_turn_committed = .{ .conversation_language = ctx.session.languageSnapshot(), .total_input_tokens = writable.state.total_input_tokens, .total_output_tokens = writable.state.total_output_tokens, .turn = prepared, } }, io_mod.milliTimestamp(), ) catch |err| { if (err == error.SessionPersistenceUncertain) ctx.prompt_snapshot_committed = true; return err; }; ctx.session.commitPreparedHistoryEntry(ctx.alloc, prepared); prepared_owned = false; ctx.prompt_snapshot_committed = true;}
fn commitContextCompaction( raw_ctx: *anyopaque, summary: types.CompactedSummaryHistoryTurn, active_prefix: ?types.AssistantHistoryTurn, retained_from: ?types.ContextHistoryCut,) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const prepared = try session_runtime.prepareCompactedHistory(ctx.alloc, ctx.session.agent.history.items, summary, retained_from orelse .{ .turns = session_runtime.rawHistoryTurnCount(ctx.session.agent.history.items) }); errdefer types.freeHistoryTurnSlice(ctx.alloc, prepared); if (ctx.writable) |*writable| { _ = writable.commitContextCompaction(ctx.alloc, summary, active_prefix, retained_from, io_mod.milliTimestamp()) catch |err| { if (err == error.SessionPersistenceUncertain and active_prefix != null) ctx.prompt_snapshot_committed = true; return err; }; if (active_prefix != null) ctx.prompt_snapshot_committed = true; } ctx.session.commitCompactedHistory(ctx.alloc, prepared);}
fn setRecoveryCheckpoint( raw_ctx: *anyopaque, checkpoint: session_codec.RecoveryCheckpoint,) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); errdefer |err| if (err == error.SessionPersistenceUncertain) { ctx.prompt_snapshot_committed = true; }; ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else return error.SessionPersistenceUnavailable; const now_ms = io_mod.milliTimestamp(); _ = try writable.appendEvent( ctx.alloc, .{ .recovery_checkpoint_set = .{ .checkpoint = checkpoint } }, now_ms, ); ctx.prompt_snapshot_committed = true;}
fn clearRecoveryCheckpoint(raw_ctx: *anyopaque) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.session_write_mutex.lockUncancelable(io_mod.getIo()); defer ctx.session_write_mutex.unlock(io_mod.getIo()); const writable = if (ctx.writable) |*value| value else return error.SessionPersistenceUnavailable; if (writable.state.recovery_checkpoint == null) return; _ = try writable.appendEvent( ctx.alloc, .{ .recovery_checkpoint_cleared = .{} }, io_mod.milliTimestamp(), );}
fn flushAskSessionUsage( ctx: *AskContext, writable: *session_store.LoadedWritableSession,) !void { const now_ms = io_mod.milliTimestamp(); var usage = try ctx.session.usage.snapshot(ctx.alloc); defer usage.deinit(ctx.alloc); _ = try writable.appendEvent( ctx.alloc, .{ .usage_checkpointed = .{ .usage = usage } }, now_ms, ); ctx.session.usage.markClean(usage);}
fn propagateGrant(_: *anyopaque, _: []const u8, _: []const u8) !void {}
fn pushEvent(raw_ctx: *anyopaque, event: WorkerEvent) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); defer worker_runtime.freeWorkerEvent(std.heap.c_allocator, event); switch (event) { .clear_route_recovery_status => ctx.last_recovery_status = null, .finish_prompt => |finished| { ctx.final_output.clearRetainingCapacity(); if (finished.terminal_outcome == .completed) switch (finished.turn) { .assistant => |turn| { const presentation = @import("../agent/runtime/assistant_stream.zig"); const text = finished.presentation_text orelse turn.assistant; const normalized = try presentation.normalizeAssistantTextForDisplay(ctx.alloc, text); defer ctx.alloc.free(normalized); try ctx.final_output.appendSlice(ctx.alloc, presentation.textForCompletedPresentation(text, normalized)); }, .compacted_summary, .interrupted => {}, }; }, else => {}, }}
fn pushText(raw_ctx: *anyopaque, emission: agent_runtime.TextEmission) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); switch (emission) { .assistant_started => { discard_restarted_json_preview(ctx); ctx.response_output_start = ctx.assistant_output.items.len; }, .assistant_restarted => |text| switch (ctx.output_mode) { .raw => try writeRawAssistantBytes(ctx, text), .json => { ctx.response_restart_pending = true; try ctx.writeStderr(text); }, .quiet => try ctx.writeStderr(text), .terminal, .terminal_no_color => try ctx.presenter.?.pushText(text), }, .assistant_source => |text| switch (ctx.output_mode) { .raw, .json => try pushRawAssistantText(ctx, text), .quiet, .terminal, .terminal_no_color => {}, }, .assistant_rendered => |text| switch (ctx.output_mode) { .terminal, .terminal_no_color => try ctx.presenter.?.pushText(text), .quiet, .json, .raw => {}, }, .operational => |text| switch (ctx.output_mode) { .terminal, .terminal_no_color => try ctx.presenter.?.pushText(text), .quiet, .json, .raw => try ctx.writeStderr(text), }, }}
fn discard_restarted_json_preview(ctx: *AskContext) void { if (!ctx.response_restart_pending) return; debug_trace.logf("agent", "discarding interrupted JSON preview bytes={d}", .{ctx.assistant_output.items.len - ctx.response_output_start}); ctx.assistant_output.shrinkRetainingCapacity(ctx.response_output_start); ctx.response_restart_pending = false; ctx.raw_has_output = ctx.assistant_output.items.len > 0; ctx.raw_boundary_pending = ctx.raw_has_output; ctx.raw_trailing_newlines = if (std.mem.endsWith(u8, ctx.assistant_output.items, "\n\n")) 2 else if (std.mem.endsWith(u8, ctx.assistant_output.items, "\n")) 1 else 0;}
fn pushRawAssistantText(ctx: *AskContext, text: []const u8) !void { if (text.len == 0) return; if (ctx.raw_boundary_pending and ctx.raw_has_output) { const separator = if (ctx.raw_trailing_newlines >= 2) "" else if (ctx.raw_trailing_newlines == 1) "\n" else "\n\n"; try writeRawAssistantBytes(ctx, separator); } ctx.raw_boundary_pending = false; try writeRawAssistantBytes(ctx, text);}
fn writeRawAssistantBytes(ctx: *AskContext, text: []const u8) !void { if (ctx.output_mode == .json) try ctx.assistant_output.appendSlice(ctx.alloc, text); if (ctx.output_mode == .raw) try ctx.writeStdout(text); if (text.len == 0) return; ctx.raw_has_output = true; var trailing: usize = 0; while (trailing < text.len and text[text.len - trailing - 1] == '\n') : (trailing += 1) {} ctx.raw_trailing_newlines = if (trailing == text.len) @min(2, ctx.raw_trailing_newlines + @as(u8, @intCast(@min(trailing, 2)))) else @intCast(@min(trailing, 2));}
fn pushToolLifecycle(raw_ctx: *anyopaque, event: types.ToolLifecycleEvent) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.output_mode.isTerminal()) { try ctx.presenter.?.pushToolLifecycle(event); } switch (event) { .provisional => |provisional| if (!ctx.output_mode.isTerminal()) { if (provisional.tool_name) |tool_name| { try beginPendingToolProgress(ctx, provisional.id.call_id, tool_name); } }, .authoritative_started => |started| { ctx.step_count += 1; if (!ctx.output_mode.isTerminal()) { try beginPendingToolProgress(ctx, started.id.call_id, started.tool_name); } if (ctx.raw_has_output) ctx.raw_boundary_pending = true; }, .progress => |progress| if (!ctx.output_mode.isTerminal()) { try publishPendingToolProgress(ctx, progress.id.call_id, progress.text); }, .turn_finished => if (!ctx.output_mode.isTerminal()) clearPendingToolProgress(ctx), .terminal => |value| if (!ctx.output_mode.isTerminal()) { try settlePendingToolProgress(ctx, value.id.call_id, value.outcome); }, }}
fn beginPendingToolProgress(ctx: *AskContext, call_id: []const u8, tool_name: []const u8) !void { ctx.pending_tool_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.pending_tool_progress_mutex.unlock(io_mod.getIo()); discardPendingToolProgressLocked(ctx, call_id);
const owned_id = try ctx.alloc.dupe(u8, call_id); errdefer ctx.alloc.free(owned_id); const owned_name = try ctx.alloc.dupe(u8, tool_name); errdefer ctx.alloc.free(owned_name); try ctx.pending_tool_progress.append(ctx.alloc, .{ .call_id = owned_id, .tool_name = owned_name, });}
fn publishPendingToolProgress(ctx: *AskContext, call_id: []const u8, text: []const u8) !void { ctx.pending_tool_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.pending_tool_progress_mutex.unlock(io_mod.getIo());
const pending = findPendingToolProgressLocked(ctx, call_id) orelse return; const owned_text = try ctx.alloc.dupe(u8, text); if (pending.text) |previous| ctx.alloc.free(previous); pending.text = owned_text; pending.visible = false; if (ctx.toolRegistry().lookup(pending.tool_name)) |tool| { if (tool.executor_kind == .web_fetch) return; } if (consumeDeferredToolProgressLocked(ctx, text)) { pending.visible = true; return; } try ctx.writeStderr(text); try ctx.writeStderr("\n"); pending.visible = true;}
fn settlePendingToolProgress(ctx: *AskContext, call_id: []const u8, outcome: types.ToolOutcome) !void { ctx.pending_tool_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.pending_tool_progress_mutex.unlock(io_mod.getIo()); var pending = takePendingToolProgressLocked(ctx, call_id) orelse return; defer pending.deinit(ctx.alloc); const context_deferred = outcome.kind == .deferred and std.mem.startsWith(u8, outcome.summary, types.context_deferred_tool_status_label ++ " "); const legacy_deferred = outcome.kind == .denied and std.mem.startsWith(u8, outcome.summary, types.deferred_tool_result_output ++ ": "); if (!context_deferred and !legacy_deferred) return; if (!pending.visible) return; const text = pending.text orelse return; try ctx.deferred_tool_progress.append(ctx.alloc, text); pending.text = null;}
fn discardPendingToolProgressLocked(ctx: *AskContext, call_id: []const u8) void { for (ctx.pending_tool_progress.items, 0..) |pending, index| { if (!std.mem.eql(u8, pending.call_id, call_id)) continue; const removed = ctx.pending_tool_progress.swapRemove(index); removed.deinit(ctx.alloc); return; }}
fn takePendingToolProgressLocked(ctx: *AskContext, call_id: []const u8) ?PendingToolProgress { for (ctx.pending_tool_progress.items, 0..) |pending, index| { if (std.mem.eql(u8, pending.call_id, call_id)) { return ctx.pending_tool_progress.swapRemove(index); } } return null;}
fn findPendingToolProgressLocked(ctx: *AskContext, call_id: []const u8) ?*PendingToolProgress { for (ctx.pending_tool_progress.items) |*pending| { if (std.mem.eql(u8, pending.call_id, call_id)) return pending; } return null;}
fn consumeDeferredToolProgressLocked(ctx: *AskContext, text: []const u8) bool { for (ctx.deferred_tool_progress.items, 0..) |progress, index| { if (!std.mem.eql(u8, progress, text)) continue; ctx.alloc.free(ctx.deferred_tool_progress.swapRemove(index)); return true; } return false;}
fn clearPendingToolProgress(ctx: *AskContext) void { ctx.pending_tool_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.pending_tool_progress_mutex.unlock(io_mod.getIo()); for (ctx.pending_tool_progress.items) |progress| progress.deinit(ctx.alloc); ctx.pending_tool_progress.clearRetainingCapacity(); for (ctx.deferred_tool_progress.items) |progress| ctx.alloc.free(progress); ctx.deferred_tool_progress.clearRetainingCapacity();}
fn onWebSearchProgress(raw_ctx: *anyopaque, _: []const u8, progress: types.WebSearchProgress) void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.output_mode.isTerminal()) return; const alloc = std.heap.c_allocator; const line = tool_presentation.formatWebSearchProgressPlain(alloc, progress) catch return; defer alloc.free(line); ctx.web_search_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.web_search_progress_mutex.unlock(io_mod.getIo()); ctx.writeLine(line) catch {};}
fn onWebFetchProgress(raw_ctx: *anyopaque, _: []const u8, progress: types.WebFetchProgress) void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.output_mode.isTerminal()) return; const alloc = std.heap.c_allocator; const line = tool_presentation.formatWebFetchProgressPlain(alloc, progress) catch return; defer alloc.free(line); ctx.web_search_progress_mutex.lockUncancelable(io_mod.getIo()); defer ctx.web_search_progress_mutex.unlock(io_mod.getIo()); ctx.writeLine(line) catch {};}
fn pushSystemNotice(raw_ctx: *anyopaque, text: []const u8) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.presenter) |presenter| return presenter.pushNotice(.{ .topic = "system", .tone = .neutral, .body = text, }); try ctx.writeStderr("[notice] "); try ctx.writeStderr(text); try ctx.writeStderr("\n");}
fn pushContextNotice(raw_ctx: *anyopaque, text: []const u8) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (!try ctx.session.claimContextNotice(ctx.alloc, text)) return; if (ctx.presenter) |presenter| { const body = try types.renderContextNoticeBody(ctx.alloc, text); defer ctx.alloc.free(body); return presenter.pushNotice(.{ .topic = "context", .tone = .warning, .body = body, .visibility = .full_only, }); } try pushSystemNotice(raw_ctx, text);}
fn pushRouteRecoveryStatus(raw_ctx: *anyopaque, status: types.RouteRecoveryStatus) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.last_recovery_status = status; const terminal = status.kind == .terminal_provider_error; const json_progress = switch (status.kind) { .auto_retry, .auto_recovered, .manual_retry_without_fast, .manual_recovered_without_fast, .terminal_provider_error, => true, .unsafe_assistant_output, .unsafe_tool_start, .content_filter, => false, }; if ((ctx.output_mode == .json and !json_progress) or (ctx.output_mode == .quiet and !terminal)) return; var label_buf: [types.RouteRecoveryStatus.label_max_bytes]u8 = undefined; try pushSystemNotice(raw_ctx, status.label(&label_buf)); if (terminal and ctx.writable == null) { try pushSystemNotice( raw_ctx, "This run was started with --no-save, so its recovery context cannot be resumed after exit.", ); }}
fn pushDiffBlock(raw_ctx: *anyopaque, payload: agent_runtime.DiffEntryPayload) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.presenter) |presenter| return presenter.pushDiffBlock(payload); defer diff_mod.freeDiffEntryPayload(std.heap.c_allocator, payload); try ctx.writeStderr(payload.preview);}
fn pushCommandOutputComplete(raw_ctx: *anyopaque, _: ?types.ToolLifecycleId) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.output_mode.isTerminal() or !ctx.command_output_line_open) return; try ctx.writeStderr("\n"); ctx.command_output_line_open = false;}
fn pushHttpError(raw_ctx: *anyopaque, status: std.http.Status, detail: []const u8, credential_source: ?types.CredentialSource) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); ctx.failed = true; const auth_failure = auth_runtime.FailureSnapshot.fromHttp(status, credential_source); ctx.auth_failure = auth_failure; const message = if (auth_failure) |failure| try failure.renderText(ctx.alloc) else try gateway_error_format.formatHttpErrorMessage(ctx.alloc, status, detail); defer ctx.alloc.free(message); try ctx.writeStderr("fx ask: "); try ctx.writeStderr(message); try ctx.writeStderr("\n"); if (ctx.output_mode.capturesJson()) { try ctx.assistant_output.appendSlice(ctx.alloc, message); try ctx.assistant_output.append(ctx.alloc, '\n'); }}
fn formatToolExecutionError(_: *anyopaque, arena: Allocator, tool_name: []const u8, err: anyerror) ![]const u8 { return tool_result_errors.formatToolExecutionErrorJson(arena, tool_name, err);}
fn onCommandOutputChunk(raw_ctx: *anyopaque, _: ?types.ToolLifecycleId, _: command_output_content.Stream, chunk: []const u8) !void { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (ctx.output_mode.isTerminal()) return; try ctx.writeStderr(chunk); if (chunk.len > 0) ctx.command_output_line_open = chunk[chunk.len - 1] != '\n';}
fn onMcpProgress(raw_ctx: *anyopaque, lifecycle_id: types.ToolLifecycleId, text: []const u8) void { pushToolLifecycle(raw_ctx, .{ .progress = .{ .id = lifecycle_id, .text = text, } }) catch |err| { debug_trace.logf("mcp", "failed to publish ask MCP progress err={s}", .{@errorName(err)}); };}
fn activateAskMcp(ctx: *AskContext) anyerror!*mcp_runtime.McpRuntime { const runtime = ctx.mcp orelse return error.McpRuntimeUnavailable; try runtime.connectDeferredForAsk(ctx.toolRegistry(), ctx.cancelFlag()); return runtime;}
fn mcpHasTool(raw_ctx: *anyopaque, name: []const u8, access: tool_mcp_runtime.Access) bool { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const mcp = ctx.mcp orelse return false; mcp.connectDeferredToolForAsk(ctx.toolRegistry(), name, access, ctx.cancelFlag()) catch return false; return mcp.hasToolWithAccess(name, access);}
fn mcpValidateTool(raw_ctx: *anyopaque, arena: Allocator, name: []const u8, arguments_json: []const u8, access: tool_mcp_runtime.Access) anyerror!tool_mcp_runtime.ValidationResult { const self: *AskContext = @ptrCast(@alignCast(raw_ctx)); const runtime = self.mcp orelse return .not_available; try runtime.connectDeferredToolForAsk(self.toolRegistry(), name, access, self.cancelFlag()); return runtime.validateToolArgumentsByNameWithAccess(arena, name, arguments_json, access);}
fn mcpCallTool(raw_ctx: *anyopaque, arena: Allocator, name: []const u8, arguments_json: []const u8, max_tool_result_bytes: usize, options: tool_mcp_runtime.CallOptions) anyerror!?tool_mcp_runtime.CallResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const mcp = ctx.mcp orelse return null; try mcp.connectDeferredToolForAsk(ctx.toolRegistry(), name, options.access, options.cancel_flag orelse ctx.cancelFlag()); return mcp.callToolByNameWithOptions( arena, name, arguments_json, max_tool_result_bytes, options, );}
fn mcpSearchTools(raw_ctx: *anyopaque, arena: Allocator, request: tool_mcp_runtime.SearchRequest, permission_rules: types.PermissionRuleSet, limits: config_runtime.context_limits.Values, access: tool_mcp_runtime.Access, cancel_flag: ?*std.atomic.Value(bool)) anyerror!tool_mcp_runtime.SearchResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const mcp = ctx.mcp orelse return error.McpRuntimeUnavailable; if (request.server) |server_name| { mcp.connectDeferredServerForAsk(ctx.toolRegistry(), server_name, access, .tools, cancel_flag orelse ctx.cancelFlag()) catch |err| switch (err) { error.McpAuthenticationRequired => {}, // Search renders the observed challenge with named login guidance. else => return err, }; } else { try mcp.connectDeferredForAsk(ctx.toolRegistry(), cancel_flag orelse ctx.cancelFlag()); } return mcp.searchToolsPrepared(arena, request, permission_rules, limits, access, cancel_flag);}
fn mcpSnapshotTool(raw_ctx: *anyopaque, arena: Allocator, name: []const u8, known: tool_mcp_runtime.Binding, permission_rules: types.PermissionRuleSet, limits: config_runtime.context_limits.Values, access: tool_mcp_runtime.Access) anyerror!tool_mcp_runtime.DefinitionSnapshot { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const runtime = ctx.mcp orelse return .unavailable; return runtime.snapshotToolDefinition(arena, name, known, permission_rules, limits, access);}
fn mcpToolSchemaJson(raw_ctx: *anyopaque, arena: Allocator, name: []const u8, permission_rules: types.PermissionRuleSet, limits: config_runtime.context_limits.Values, access: tool_mcp_runtime.Access, cancel_flag: ?*std.atomic.Value(bool)) anyerror!?tool_mcp_runtime.ToolSchemaResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const runtime = ctx.mcp orelse return null; try runtime.connectDeferredToolForAsk(ctx.toolRegistry(), name, access, cancel_flag orelse ctx.cancelFlag()); return runtime.toolSchemaJsonByNameWithAccess(arena, name, permission_rules, limits, access, cancel_flag);}
fn mcpCallFeature( raw_ctx: *anyopaque, arena: Allocator, request: tool_mcp_runtime.FeatureRequest, options: tool_mcp_runtime.FeatureCallOptions,) anyerror!tool_mcp_runtime.FeatureResult { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); const mcp = ctx.mcp orelse return error.McpRuntimeUnavailable; if (!options.access.allowsFeatureServer(mcp.generation, request.server_name)) return error.McpServerNotFound; try mcp.connectDeferredServerForAsk(ctx.toolRegistry(), request.server_name, options.access, .features, options.cancel_flag orelse ctx.cancelFlag()); return mcp.callFeatureForModel(arena, request, options);}
fn respondToAskMcpInput( raw_ctx: *anyopaque, alloc: Allocator, origin: tool_mcp_runtime.InputOrigin, required: tool_mcp_runtime.InputRequired,) anyerror![]const u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (!ctx.mcp_elicitation_capabilities.any()) return error.McpInputRequired; return mcp_elicitation_interaction.respond(alloc, origin, required, .{ .questioner = .{ .context = raw_ctx, .ask_fn = askTerminalMcpQuestion }, .browser = .{ .context = raw_ctx, .open_fn = openAskMcpUrl }, .capabilities = ctx.mcp_elicitation_capabilities, });}
fn askTerminalMcpQuestion( raw_ctx: *anyopaque, alloc: Allocator, entries: []const types.QuestionBatchEntry, deadline_ms: i64, lifecycle_cancel_flag: ?*const std.atomic.Value(bool),) anyerror!?[][]u8 { const ctx: *AskContext = @ptrCast(@alignCast(raw_ctx)); if (!ctx.mcp_elicitation_capabilities.any()) return null; const answers = try alloc.alloc([]u8, entries.len); var answered: usize = 0; errdefer { for (answers[0..answered]) |answer| alloc.free(answer); alloc.free(answers); } for (entries) |entry| { try ctx.writeStderr("\n"); try ctx.writeStderr(entry.question); try ctx.writeStderr("\n"); for (entry.options, 0..) |option, index| { var number_buffer: [32]u8 = undefined; const prefix = try std.fmt.bufPrint(&number_buffer, " {d}. ", .{index + 1}); try ctx.writeStderr(prefix); try ctx.writeStderr(option.label); if (option.description) |description| { try ctx.writeStderr(" — "); try ctx.writeStderr(description); } try ctx.writeStderr("\n"); } if (entry.options.len > 0) { try ctx.writeStderr("Choose a number or enter a value: "); } else { try ctx.writeStderr("Enter a value: "); } ctx.dispatchAttentionRequired(.question); const line = try readAskTerminalLine( ctx, alloc, deadline_ms, lifecycle_cancel_flag, ) orelse { if (askAwakeMillis() >= deadline_ms) return error.McpInputTimedOut; return null; }; defer alloc.free(line); answers[answered] = try mapTerminalAnswer(alloc, entry.options, line); answered += 1; } return answers;}
fn mapTerminalAnswer( alloc: Allocator, options: []const types.QuestionOption, line: []const u8,) ![]u8 { const trimmed = std.mem.trim(u8, line, " \t\r\n"); const ordinal = std.fmt.parseInt(usize, trimmed, 10) catch 0; if (ordinal > 0 and ordinal <= options.len) return alloc.dupe(u8, options[ordinal - 1].label); for (options) |option| { if (std.mem.eql(u8, trimmed, option.label)) return alloc.dupe(u8, option.label); } return alloc.dupe(u8, trimmed);}
fn openAskMcpUrl(_: ?*anyopaque, alloc: Allocator, url: []const u8) anyerror!bool { return url_opener.native_opener.open(alloc, url);}
const AskReadEvent = union(enum) { line: anyerror![]u8, deadline: anyerror!void, cancelled: anyerror!void,};
fn readAskTerminalLine( ctx: *AskContext, alloc: Allocator, deadline_ms: i64, lifecycle_cancel_flag: ?*const std.atomic.Value(bool),) !?[]u8 { const Cleanup = struct { fn drain(result_alloc: Allocator, select: *std.Io.Select(AskReadEvent)) void { while (select.cancel()) |item| switch (item) { .line => |line_result| { const line = line_result catch continue; result_alloc.free(line); }, .deadline, .cancelled => {}, }; } };
var select_buffer: [3]AskReadEvent = undefined; var select: std.Io.Select(AskReadEvent) = .init(io_mod.getIo(), &select_buffer); try select.concurrent(.line, readOwnedStdinLine, .{alloc}); select.concurrent(.deadline, waitForAskDeadline, .{deadline_ms}) catch |err| { Cleanup.drain(alloc, &select); return err; }; select.concurrent(.cancelled, waitForAskCancellation, .{ ctx, lifecycle_cancel_flag }) catch |err| { Cleanup.drain(alloc, &select); return err; }; const event = select.await() catch |err| { Cleanup.drain(alloc, &select); return err; }; return switch (event) { .line => |line_result| result: { const line = line_result catch { Cleanup.drain(alloc, &select); break :result null; }; Cleanup.drain(alloc, &select); break :result line; }, .deadline, .cancelled => result: { Cleanup.drain(alloc, &select); break :result null; }, };}
fn readOwnedStdinLine(alloc: Allocator) anyerror![]u8 { var read_buffer: [64 * 1024]u8 = undefined; var reader = std.Io.File.stdin().reader(io_mod.getIo(), &read_buffer); const line = try reader.interface.takeDelimiter('\n') orelse return error.EndOfStream; return alloc.dupe(u8, line);}
fn waitForAskDeadline(deadline_ms: i64) anyerror!void { while (askAwakeMillis() < deadline_ms) { const remaining = deadline_ms - askAwakeMillis(); const delay_ms: i64 = @min(remaining, 20); try io_mod.getIo().sleep(.fromMilliseconds(@intCast(@max(delay_ms, 1))), .awake); }}
fn askAwakeMillis() i64 { const now = std.Io.Clock.Timestamp.now(io_mod.getIo(), .awake); const milliseconds = @divFloor(now.raw.nanoseconds, std.time.ns_per_ms); return std.math.cast(i64, milliseconds) orelse if (milliseconds < 0) std.math.minInt(i64) else std.math.maxInt(i64);}
fn waitForAskCancellation( ctx: *AskContext, lifecycle_cancel_flag: ?*const std.atomic.Value(bool),) anyerror!void { while (!ctx.cancelFlag().load(.acquire) and (lifecycle_cancel_flag == null or !lifecycle_cancel_flag.?.load(.acquire))) { try io_mod.getIo().sleep(.fromMilliseconds(10), .awake); }}
fn resolveAskSubagentAuthority( raw: ?*anyopaque, alloc: Allocator, root_id: []const u8,) subagent_authority.HostResolveError!subagent_authority.HostAuthority { const ctx: *AskContext = @ptrCast(@alignCast(raw.?)); const writable = if (ctx.writable) |*value| value else return error.HostAuthorityUnavailable; if (!std.mem.eql(u8, writable.active_id, root_id)) { return error.HostAuthorityUnavailable; } if (ctx.mcp != null) { _ = activateAskMcp(ctx) catch return error.HostAuthorityUnavailable; } const integrations = if (ctx.mcp) |mcp| mcp.snapshotToolNames(alloc, ctx.permission_rules) else alloc.alloc([]u8, 0); const owned_integrations = integrations catch return error.OutOfMemory; defer { for (owned_integrations) |name| alloc.free(name); alloc.free(owned_integrations); } var mcp_view = if (ctx.mcp) |mcp| try mcp.snapshotAccessView( alloc, root_id, root_id, ctx.permission_rules, ctx.cfg.mode_registry.toolAllowed(ctx.deps.tool_set, ctx.mode_id, "mcp_features") and !permissions.rulesDenyAllTargetsForTool(ctx.permission_rules, "mcp_features"), ) else null; defer if (mcp_view) |*view| view.deinit(alloc); var permission_state = ctx.session.snapshotPermissionState(alloc) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.HostAuthorityUnavailable, }; defer permission_state.deinit(alloc); return subagent_tool_host.captureHostAuthorityWithMcpView( alloc, .{ .tool_set = ctx.deps.tool_set, .mode = .{ .active = .{ .registry = ctx.cfg.mode_registry, .id = ctx.mode_id, }, }, }, owned_integrations, ctx.permission_rules, &.{}, permission_state, if (mcp_view) |*view| view else null, );}
fn parseOptionsWithStdin(alloc: Allocator, args: []const [:0]const u8, stdin: StdinSource) !AskOptions { var opts: AskOptions = .{ .prompt = &.{} }; errdefer opts.deinit(alloc);
var prompt_parts: std.ArrayList([]const u8) = .empty; defer prompt_parts.deinit(alloc);
var options_ended = false; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (options_ended) { try prompt_parts.append(alloc, arg); } else if (std.mem.eql(u8, arg, "--")) { options_ended = true; } else if (std.mem.eql(u8, arg, "--auto")) { if (opts.permission_override != null) return error.InvalidAskArgs; opts.permission_override = .auto; } else if (std.mem.eql(u8, arg, "--full-access") or std.mem.eql(u8, arg, "--yolo")) { if (opts.permission_override != null) return error.InvalidAskArgs; opts.permission_override = .yolo; } else if (std.mem.eql(u8, arg, "--model")) { i += 1; if (i >= args.len) return error.InvalidAskArgs; const model = std.mem.trim(u8, args[i], " \t\r\n"); if (model.len == 0) return error.InvalidAskArgs; const owned_model = try alloc.dupe(u8, model); if (opts.model_override) |old| alloc.free(old); opts.model_override = owned_model; } else if (std.mem.eql(u8, arg, "--effort")) { i += 1; if (i >= args.len) return error.InvalidAskArgs; opts.effort_override = types.ReasoningEffort.parse(args[i]) orelse return error.InvalidAskArgs; } else if (std.mem.eql(u8, arg, "--fast") or std.mem.eql(u8, arg, "--no-fast")) { const enabled = std.mem.eql(u8, arg, "--fast"); if (opts.fast_override != null and opts.fast_override.? != enabled) return error.InvalidAskArgs; opts.fast_override = enabled; } else if (std.mem.eql(u8, arg, "--resume") or std.mem.eql(u8, arg, "--resume-id")) { if (opts.resume_target != null) return error.InvalidAskArgs; const exact_id = std.mem.eql(u8, arg, "--resume-id"); i += 1; if (i >= args.len) return error.InvalidAskArgs; const target = std.mem.trim(u8, args[i], " \t\r\n"); if (target.len == 0) return error.InvalidAskArgs; opts.resume_target = if (!exact_id and std.mem.eql(u8, target, "last")) .last else .{ .id = target }; } else if (std.mem.eql(u8, arg, "--image")) { i += 1; if (i >= args.len) return error.MissingPrompt; try opts.image_paths.append(alloc, try alloc.dupe(u8, args[i])); } else if (std.mem.eql(u8, arg, "--system")) { i += 1; if (i >= args.len) return error.MissingPrompt; if (opts.system_prompt_override) |old| alloc.free(old); opts.system_prompt_override = try alloc.dupe(u8, args[i]); } else if (std.mem.eql(u8, arg, "--json")) { opts.json_output = true; } else if (std.mem.eql(u8, arg, "--prompt-permissions")) { opts.prompt_permissions = true; } else if (std.mem.eql(u8, arg, "--timeout")) { i += 1; if (i >= args.len) return error.MissingPrompt; opts.timeout_ms = parseTimeoutMs(args[i]); } else if (std.mem.eql(u8, arg, "--quiet")) { opts.quiet = true; } else if (std.mem.eql(u8, arg, "--verbose")) { opts.verbose = true; } else if (std.mem.eql(u8, arg, "--no-save")) { opts.no_save = true; } else if (std.mem.eql(u8, arg, "--no-color")) { opts.no_color = true; } else if (std.mem.eql(u8, arg, "--continue-recovery")) { if (opts.continue_recovery) return error.InvalidAskArgs; opts.continue_recovery = true; } else if (arg.len > 1 and arg[0] == '-') { return error.InvalidAskArgs; } else { try prompt_parts.append(alloc, arg); } }
if (opts.continue_recovery) { if (opts.resume_target == null or opts.no_save or prompt_parts.items.len != 0 or opts.image_paths.items.len != 0) { return error.InvalidAskArgs; } opts.prompt = try alloc.dupe(u8, ""); } else if (prompt_parts.items.len > 0) { opts.prompt = try joinPromptSlices(alloc, prompt_parts.items); } else { opts.prompt = try readPromptFromStdinSource(alloc, stdin); } if (!text_utils.isModelSafeText(opts.prompt)) return error.InvalidPromptText; if (opts.no_save and opts.resume_target != null) return error.NoSaveResumeConflict;
return opts;}
fn emitHeadlessYoloWarning(alloc: Allocator, options: RunOptions) !void { if (options.color_enabled and options.deps.stderr_is_tty(options.deps.stderr_ctx)) { try options.deps.write_stderr( options.deps.stderr_ctx, "\x1b[38;5;252m" ++ permissions.yolo_warning_text ++ "\x1b[0m\n", ); } else { try options.deps.write_stderr(options.deps.stderr_ctx, permissions.yolo_warning_text ++ "\n"); }
var attempt = options.deps.persist_yolo_acknowledgment(alloc); defer attempt.deinit(alloc); switch (attempt) { .outcome => {}, .failure => |failure| { var message: std.Io.Writer.Allocating = .init(alloc); defer message.deinit(); try message.writer.print( "fx ask: failed to save full access acknowledgment: {s}\n", .{@errorName(failure.err)}, ); try options.deps.write_stderr(options.deps.stderr_ctx, message.written()); }, }}
fn persistYoloAcknowledgmentDefault(alloc: Allocator) config_runtime.CommitAttempt { return config_runtime.attemptUserPreferences( alloc, .{ .yolo_acknowledged = true }, );}
fn hasJsonFlag(args: []const [:0]const u8) bool { for (args) |arg| { if (std.mem.eql(u8, arg, "--")) return false; if (std.mem.eql(u8, arg, "--json")) return true; } return false;}
fn parseTimeoutMs(raw: []const u8) ?usize { const seconds = std.fmt.parseInt(usize, raw, 10) catch return null; return std.math.mul(usize, seconds, std.time.ms_per_s) catch null;}
fn joinPromptSlices(alloc: Allocator, args: []const []const u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); for (args, 0..) |arg, idx| { if (idx > 0) try out.writer.writeByte(' '); try out.writer.writeAll(arg); } return try out.toOwnedSlice();}
fn readPromptFromStdinSource(alloc: Allocator, stdin: StdinSource) ![]u8 { return readPromptFromStdinSourceWithLimit(alloc, stdin, stdin_prompt_resource_byte_limit);}
fn readPromptFromStdinSourceWithLimit( alloc: Allocator, stdin: StdinSource, resource_byte_limit: usize,) ![]u8 { const input = switch (stdin) { .real => blk: { if (try std.Io.File.stdin().isTty(io_mod.getIo())) return error.MissingPrompt; break :blk try readPromptFromStdin(alloc, resource_byte_limit); }, .tty => return error.MissingPrompt, .bytes => |bytes| blk: { if (bytes.len > resource_byte_limit) return error.PromptResourceLimitExceeded; break :blk try alloc.dupe(u8, bytes); }, .read_error => return error.PromptInputReadFailed, }; errdefer alloc.free(input);
const trimmed = std.mem.trim(u8, input, " \t\r\n"); if (trimmed.len == 0) return error.MissingPrompt; if (trimmed.len == input.len) return input;
const owned = try alloc.dupe(u8, trimmed); alloc.free(input); return owned;}
fn readPromptFromStdin(alloc: Allocator, resource_byte_limit: usize) ![]u8 { const zio = io_mod.getIo(); var read_buf: [8192]u8 = undefined; var r = std.Io.File.stdin().reader(zio, &read_buf); return readPromptFromReader(alloc, &r.interface, resource_byte_limit);}
fn readPromptFromReader( alloc: Allocator, reader: *std.Io.Reader, resource_byte_limit: usize,) ![]u8 { const detection_limit = std.math.add(usize, resource_byte_limit, 1) catch return error.PromptResourceLimitExceeded; const input = reader.allocRemaining(alloc, .limited(detection_limit)) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.StreamTooLong => return error.PromptResourceLimitExceeded, else => return error.PromptInputReadFailed, }; if (input.len > resource_byte_limit) { alloc.free(input); return error.PromptResourceLimitExceeded; } return input;}
fn renderFinalJsonResult(alloc: Allocator, result: PromptRunResult) ![]u8 { var out: std.Io.Writer.Allocating = .init(alloc); errdefer out.deinit();
try out.writer.writeAll("{\"output\":"); try std.json.Stringify.value(result.assistant_output, .{}, &out.writer); try out.writer.writeAll(",\"final_output\":"); try std.json.Stringify.value(result.final_output, .{}, &out.writer); try out.writer.print(",\"exit_code\":{d}", .{result.exit_code}); try out.writer.writeAll(",\"model\":"); try std.json.Stringify.value(result.model, .{}, &out.writer); try out.writer.writeAll(",\"session_id\":"); try std.json.Stringify.value(result.session_id, .{}, &out.writer); try out.writer.print(",\"steps\":{d}", .{result.step_count}); try out.writer.writeAll(",\"tool_calls\":["); for (result.tool_calls, 0..) |tc, i| { if (i > 0) try out.writer.writeAll(","); try out.writer.writeAll("{\"name\":"); try std.json.Stringify.value(tc.name, .{}, &out.writer); try out.writer.writeAll(",\"status\":"); try std.json.Stringify.value(tc.status, .{}, &out.writer); if (tc.action) |action| { try out.writer.writeAll(",\"action\":"); try std.json.Stringify.value(@tagName(action), .{}, &out.writer); } if (tc.error_category) |category| { try out.writer.writeAll(",\"error\":{\"category\":"); try std.json.Stringify.value(@tagName(category), .{}, &out.writer); try out.writer.writeAll(",\"code\":"); try std.json.Stringify.value( tc.error_code orelse "tool_failed", .{}, &out.writer, ); try out.writer.writeByte('}'); } if (tc.command_result_json) |json| { try out.writer.writeAll(",\"command_result\":"); try out.writer.writeAll(json); } if (tc.ask_question_text) |text| { try out.writer.writeAll(",\"question\":"); try std.json.Stringify.value(text, .{}, &out.writer); } if (tc.web_search_completion) |completion| { try out.writer.print(",\"web_search\":{{\"searches\":{d},\"duration_ms\":{d}}}", .{ completion.searches, completion.duration_ms }); } if (tc.web_fetch_completion) |completion| { try out.writer.writeAll(",\"web_fetch\":{\"url\":"); try std.json.Stringify.value(completion.url(), .{}, &out.writer); try out.writer.print(",\"bytes\":{d},\"status\":{d},\"duration_ms\":{d},\"cache_hit\":{s},\"artifact\":\"{s}\"}}", .{ completion.bytes, completion.status, completion.duration_ms, if (completion.cache_hit) "true" else "false", @tagName(completion.artifact_state), }); } try out.writer.writeAll("}"); } try out.writer.writeAll("],\"usage\":"); try std.json.Stringify.value(.{ .input_tokens = result.usage.input_tokens, .output_tokens = result.usage.output_tokens, }, .{}, &out.writer); if (result.error_code) |error_code| { try out.writer.writeAll(",\"error\":"); try std.json.Stringify.value(error_code, .{}, &out.writer); } if (result.auth_failure) |failure| { try out.writer.writeAll(",\"auth_failure\":"); try failure.writeJson(&out.writer); } if (result.recovery) |recovery| { var label_buf: [types.RouteRecoveryStatus.label_max_bytes]u8 = undefined; try out.writer.writeAll(",\"recovery\":{\"state\":"); try std.json.Stringify.value( if (recovery.kind == .terminal_provider_error) // A genuine lifecycle pause (action == .paused) is resumable; a // terminal stop (no action) is not. JSON consumers need the // distinction. if (recovery.action == .paused) "paused" else "failed" else if (recovery.isRecovered()) "recovered" else "active", .{}, &out.writer, ); try out.writer.writeAll(",\"kind\":"); try std.json.Stringify.value(@tagName(recovery.kind), .{}, &out.writer); if (recovery.cause) |cause| { try out.writer.writeAll(",\"cause\":"); try std.json.Stringify.value(@tagName(cause), .{}, &out.writer); } if (recovery.action) |action| { try out.writer.writeAll(",\"action\":"); try std.json.Stringify.value(@tagName(action), .{}, &out.writer); } if (recovery.required_action != .none) { try out.writer.writeAll(",\"required_action\":"); try std.json.Stringify.value(@tagName(recovery.required_action), .{}, &out.writer); } try out.writer.print(",\"attempt\":{d},\"attempt_limit\":{d},\"delay_seconds\":{d},\"durable\":{s},\"message\":", .{ recovery.reportedAttempt(), recovery.attempt_limit, recovery.delay_seconds, if (result.recovery_durable) "true" else "false", }); try std.json.Stringify.value(recovery.label(&label_buf), .{}, &out.writer); try out.writer.writeAll("}"); } try out.writer.writeAll("}\n"); return try out.toOwnedSlice();}
fn renderErrorJsonResult(alloc: Allocator, err_name: []const u8) ![]u8 { return renderFinalJsonResult(alloc, .{ .exit_code = 1, .assistant_output = &.{}, .error_code = err_name, });}
fn toCoreReasoningEffort(effort: types.ReasoningEffort) types.ReasoningEffort { return effort;}
fn toCorePermissionMode(mode: anytype) PermissionMode { return switch (mode) { .ask => .ask, .auto => .auto, .yolo => .yolo, };}
fn takeCorePermissionRules(_: Allocator, startup: *app_lifecycle.StartupState) !types.PermissionRuleSet { return startup.takePermissionRules();}
fn loadStartupStateDefault( alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize,) !app_lifecycle.StartupState { return app_lifecycle.loadStartupState( alloc, transport, secret_store, default_model, default_agent_step_limit, );}
fn initializeSessionStoresDefault(ctx: *AskContext) !void { try ctx.initializeSessionStores();}
fn testInitializeSessionStoresOneOffDenied(_: *AskContext) !void { return error.OneOffSessionNotResumable;}
fn processQueuedPromptDefault(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, config: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { return agent_runtime.processAgentPrompt(agent, deps, semantic_presentation, lifecycle, config, job);}
fn discardPristineSessionDefault( _: ?*anyopaque, ctx: *AskContext, loaded: *session_store.LoadedWritableSession,) session_store.PristineDiscardDisposition { return ctx.store.?.discardPristineStartedSession(ctx.alloc, loaded);}
fn promptRealPermissionApproval( _: ?*anyopaque, stderr_ctx: ?*anyopaque, write_stderr: WriteFn, label: []const u8, attention_ctx: ?*anyopaque, notify_attention: NotifyAttentionFn,) !PermissionApprovalPromptResult { const zio = io_mod.getIo(); const stdin_tty = std.Io.File.stdin().isTty(zio) catch false; if (!stdin_tty) return .unavailable;
try write_stderr(stderr_ctx, "fx wants to run:\n "); try write_stderr(stderr_ctx, label); try write_stderr(stderr_ctx, "\n\nApprove? [y/N] "); notify_attention(attention_ctx);
var read_buf: [256]u8 = undefined; var reader = std.Io.File.stdin().reader(zio, &read_buf); const line = reader.interface.takeDelimiter('\n') catch return .deny; const answer = std.mem.trim(u8, line orelse "", " \t\r\n"); if (answer.len == 1 and (answer[0] == 'y' or answer[0] == 'Y')) return .approve; return .deny;}
fn realStderrIsTty(_: ?*anyopaque) bool { return std.Io.File.stderr().isTty(io_mod.getIo()) catch false;}
fn realStdinIsTty(_: ?*anyopaque) bool { return std.Io.File.stdin().isTty(io_mod.getIo()) catch false;}
fn realStdoutIsTty(_: ?*anyopaque) bool { return std.Io.File.stdout().isTty(io_mod.getIo()) catch false;}
fn writeRealStdout(_: ?*anyopaque, text: []const u8) !void { try std.Io.File.stdout().writeStreamingAll(io_mod.getIo(), text);}
fn writeRealStderr(_: ?*anyopaque, text: []const u8) !void { try std.Io.File.stderr().writeStreamingAll(io_mod.getIo(), text);}
const TestCapture = struct { bytes: std.ArrayList(u8) = .empty,
fn deinit(self: *TestCapture, alloc: Allocator) void { self.bytes.deinit(alloc); }
fn write(raw_ctx: ?*anyopaque, text: []const u8) !void { const self: *TestCapture = @ptrCast(@alignCast(raw_ctx.?)); try self.bytes.appendSlice(std.testing.allocator, text); }};
const TestPermissionPrompt = struct { result: PermissionApprovalPromptResult = .unavailable, calls: usize = 0, label: ?[]const u8 = null,
fn prompt( raw_ctx: ?*anyopaque, stderr_ctx: ?*anyopaque, write_stderr: WriteFn, label: []const u8, attention_ctx: ?*anyopaque, notify_attention: NotifyAttentionFn, ) !PermissionApprovalPromptResult { const self: *@This() = @ptrCast(@alignCast(raw_ctx.?)); self.calls += 1; self.label = label; if (self.result != .unavailable) { try write_stderr(stderr_ctx, "fx wants to run:\n "); try write_stderr(stderr_ctx, label); try write_stderr(stderr_ctx, "\n\nApprove? [y/N] "); notify_attention(attention_ctx); } return self.result; }};
const TestTty = struct { fn yes(_: ?*anyopaque) bool { return true; }
fn no(_: ?*anyopaque) bool { return false; }};
const TestYoloPersistence = struct { var calls: usize = 0; var warning_capture: ?*const TestCapture = null; var observed_warning_before_persist: bool = false;
fn reset() void { calls = 0; warning_capture = null; observed_warning_before_persist = false; }
fn persist(_: Allocator) config_runtime.CommitAttempt { calls += 1; if (warning_capture) |capture| { observed_warning_before_persist = std.mem.find(u8, capture.bytes.items, permissions.yolo_warning_text) != null; } return .{ .outcome = .unchanged }; }};
const TestFailingStderr = struct { fn write(_: ?*anyopaque, _: []const u8) !void { return error.TestStderrWriteFailed; }};
const AskAttentionCapture = struct { calls: usize = 0, turn_id: u64 = 0, kind: ?hooks.AttentionKind = null,
fn run(raw: *anyopaque, input: hooks.AttentionRequiredInput) hooks.HandlerError!void { const self: *AskAttentionCapture = @ptrCast(@alignCast(raw)); self.calls += 1; self.turn_id = input.invocation.turn_id orelse 0; self.kind = input.kind; }};
const test_modes = [_]mode_registry.ModeSpec{ .{ .id = "inspect", .name = "Inspect", .permission_mode = .ask, },};
const test_mode_registry = mode_registry.Registry{ .default_mode_id = "inspect", .modes = test_modes[0..],};
fn testModelPromptOverlay(model: []const u8) ?[]const u8 { return if (std.mem.eql(u8, model, "model")) "test model overlay" else null;}
fn testConfig() Config { return .{ .command_usage = "ask [--auto|--full-access] [--image PATH] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--] <prompt>", .default_model = "model", .default_agent_step_limit = 4, .gateway_retry_count = 1, .gateway_chat_url = "https://example.invalid/chat", .gateway_models_path = "/models", .gateway_provider = test_builtin_gateway.provider, .provider_set = provider_set.gateway_only(test_builtin_gateway.provider_bundle), .secret_store = host.unavailable_secret_store, .prompt_policy = .{ .system_prompt = "system", .model_prompt_overlay_fn = testModelPromptOverlay, }, .skill_root_policy = .{ .managed_root_source = .global_fx }, .ignored_list_entries = &.{}, .max_list_entries = 10, .max_read_file_bytes = 1024, .max_read_file_lines = 100, .max_read_file_line_len = 200, .max_command_output_bytes = 4096, .max_tool_result_bytes = 4096, .max_history_turns = 2, .mode_registry = test_mode_registry, .load_mcp_runtime = testNoMcpRuntime, };}
fn testMissingKeyStartup(alloc: Allocator, _: oauth_transport.Provider, _: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = app_lifecycle.StartupState{ .agent_step_limit = default_agent_step_limit }; errdefer state.deinit(alloc); state.workspace_root = try alloc.dupe(u8, "/tmp/fx-test"); state.selected_model = try alloc.dupe(u8, default_model); state.context_enabled = false; return state;}
fn testPresentKeyStartup(alloc: Allocator, _: oauth_transport.Provider, _: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = app_lifecycle.StartupState{ .agent_step_limit = default_agent_step_limit }; errdefer state.deinit(alloc); state.workspace_root = try alloc.dupe(u8, "/tmp/fx-test"); state.credential = .{ .token = try alloc.dupe(u8, "key"), .source = .ai_gateway_api_key, }; state.selected_model = try alloc.dupe(u8, default_model); state.context_enabled = true; return state;}
fn testMissingKeyAcknowledgedStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = try testMissingKeyStartup(alloc, transport, secret_store, default_model, default_agent_step_limit); state.yolo_acknowledged = true; return state;}
fn testMissingKeyDiagnosticStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = try testMissingKeyStartup(alloc, transport, secret_store, default_model, default_agent_step_limit); errdefer state.deinit(alloc); state.config_diagnostics = try alloc.alloc(config_runtime.ConfigDiagnostic, 1); state.config_diagnostics[0] = .{ .layer = .user, .cause = .malformed_settings, }; return state;}
fn testPresentKeySavedStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = try testPresentKeyStartup(alloc, transport, secret_store, default_model, default_agent_step_limit); errdefer state.deinit(alloc); state.configured_model = try alloc.dupe(u8, default_model); return state;}
fn testPushAssistantText(deps: *const agent_runtime.AgentRuntimeDeps, text: []const u8) !void { try deps.push_text(deps.ctx, .{ .assistant_source = text }); try deps.push_text(deps.ctx, .{ .assistant_rendered = text });}
fn testProcessQueuedPrompt(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try std.testing.expectEqual(hooks.ScopeKind.ask, lifecycle.scope.kind); try std.testing.expectEqualStrings("/tmp/fx-test", lifecycle.scope.workspace_root); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptRecoveryLifecycle(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try std.testing.expectEqual(hooks.ScopeKind.ask, lifecycle.scope.kind); try deps.push_route_recovery_status(deps.ctx, .{ .kind = .auto_retry, .failed_attempt = 1, .attempt_limit = 10, .cause = .network_interrupted, .action = .waiting_for_connectivity, }); try deps.push_event(deps.ctx, .clear_route_recovery_status); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptRetryAdmissionFailure(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try deps.push_route_recovery_status(deps.ctx, .{ .kind = .auto_retry, .failed_attempt = 1, .attempt_limit = 2, .delay_seconds = 4, }); try deps.push_route_recovery_status(deps.ctx, .{ .kind = .terminal_provider_error, .failed_attempt = 1, .attempt_limit = 2, .diagnostic = types.ModelFailureDiagnostic.init("TestProviderSerializationFailed"), }); return error.TestProviderSerializationFailed;}
fn testProcessQueuedPromptPartialThenReadFailed(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try testPushAssistantText(deps, "partial "); try testPushAssistantText(deps, "résumé"); return error.ReadFailed;}
fn testProcessQueuedPromptRepeatsSkillDiagnostic(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { try std.testing.expectEqual(@as(usize, 1), cfg.skill_catalog.skills.len); try std.testing.expectEqualStrings("visible", cfg.skill_catalog.skills[0].name); try std.testing.expectEqual(@as(usize, 1), cfg.skill_catalog.diagnostics.len); try std.testing.expectEqual(@as(usize, 0), cfg.context_limits.skill_catalog_bytes.effectiveBytes()); const diagnostics = [_]skill_runtime.SkillDiagnostic{.{ .path = "/tmp/bad-skill/SKILL.md", .source = .workspace_shared, .scope = .candidate, .cause = .{ .invalid_metadata = .missing_name }, }}; var notice: std.Io.Writer.Allocating = .init(std.testing.allocator); defer notice.deinit(); try skill_runtime.writeDiagnosticSummary(std.testing.allocator, ¬ice.writer, &diagnostics); try deps.push_context_notice.?(deps.ctx, notice.written()); try deps.push_context_notice.?(deps.ctx, notice.written()); try testProcessQueuedPrompt(agent, deps, semantic_presentation, lifecycle, cfg, job);}
fn testProcessQueuedPromptChecksTimeout(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); const ctx: *AskContext = @ptrCast(@alignCast(deps.ctx)); try std.testing.expectEqual(@as(?usize, std.time.ms_per_s), ctx.command_timeout_ms); const tool_ctx = ctx.toolContext(); try std.testing.expectEqual(@as(?usize, std.time.ms_per_s), tool_ctx.command_timeout_ms); try std.testing.expect(!tool_ctx.web_search_runtime_ready); try std.testing.expect(tool_ctx.web_search_backend != null); try std.testing.expect(tool_ctx.web_fetch_runtime.? == &ctx.web_fetch_runtime); try std.testing.expect(tool_ctx.web_fetch_progress_ctx != null); try std.testing.expect(tool_ctx.on_web_fetch_progress != null); try std.testing.expectEqualStrings(ctx.model, ctx.web_search_runtime.worker_model); try std.testing.expectEqual(ctx.cfg.gateway_retry_count, ctx.web_search_runtime.gateway_retry_count); try std.testing.expectEqualStrings(ctx.cfg.gateway_chat_url, ctx.web_search_runtime.gateway_chat_url); try std.testing.expect(ctx.web_search_runtime.provider.?.execute_fn == ctx.cfg.provider_set.gateway.fx_search.?.execute_fn); try std.testing.expectEqualStrings("/models", tool_ctx.gateway_models_path); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptChecksExecOnlyTerminal(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try std.testing.expect(cfg.session_child_capability == null); try std.testing.expect(cfg.ephemeral_command_replay != null); const ctx: *AskContext = @ptrCast(@alignCast(deps.ctx)); try std.testing.expect(ctx.toolContext().ephemeral_command_replay != null); try std.testing.expectEqualStrings("inspect", ctx.mode_id); try std.testing.expect(tool_projection_mod.containsName(cfg.advertised_tool_names, "read_file")); try std.testing.expect(tool_projection_mod.containsName(cfg.advertised_tool_names, "shell")); try std.testing.expect(!tool_projection_mod.containsName(cfg.advertised_tool_names, "run_command")); try std.testing.expect(tool_projection_mod.containsName(cfg.advertised_tool_names, "web_search")); const advertised_shell = for (cfg.advertised_functions) |function| { if (std.mem.eql(u8, function.name, "shell")) break function; } else return error.TestExpectedEqual; try std.testing.expect(model_tool_schema.isSingleRequiredObjectUnionField( advertised_shell.input_schema, "request", )); try std.testing.expect(std.mem.find(u8, advertised_shell.description, "shell.interact") != null); try std.testing.expectEqualStrings(builtin_tools.web_search.description, cfg.custom_tool_guidance); try std.testing.expectEqualStrings("test model overlay", cfg.model_prompt_overlay.?); const runtime_shell = deps.tool_registry.lookup("shell") orelse return error.TestExpectedEqual; try std.testing.expect(std.mem.find(u8, runtime_shell.description, "shell.interact") != null); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptChecksFullTerminal(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try std.testing.expect(cfg.session_child_capability != null); try std.testing.expect(tool_projection_mod.containsName(cfg.advertised_tool_names, "shell")); const advertised_shell = for (cfg.advertised_functions) |function| { if (std.mem.eql(u8, function.name, "shell")) break function; } else return error.TestExpectedEqual; try std.testing.expect(std.mem.find(u8, advertised_shell.description, "shell.interact") != null); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptChecksInjectedToolSet(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try std.testing.expect(tool_projection_mod.containsName(cfg.advertised_tool_names, "read_file")); try std.testing.expect(!tool_projection_mod.containsName(cfg.advertised_tool_names, "run_command")); try std.testing.expectEqualStrings("", cfg.custom_tool_guidance);
const ctx: *AskContext = @ptrCast(@alignCast(deps.ctx)); try std.testing.expect(ctx.toolRegistry().lookup("read_file") != null); try std.testing.expect(ctx.toolRegistry().lookup("run_command") == null); try testPushAssistantText(deps, "assistant text");}
fn testProcessQueuedPromptHttp413(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try deps.push_http_error( deps.ctx, .payload_too_large, "provider payload rejected\n\nprompt_too_long=true\nProvider rejected the prompt as too large. Latest local tool evidence remains in session history/result handles; no local tool actions were replayed.", null, );}
fn testProcessQueuedPromptRestrictedProvider(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, _: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try deps.push_http_error( deps.ctx, .forbidden, "{\"error\":{\"message\":\"Your team has restricted access to this provider. Contact the owner of the account for more details. Providers considered: wafer\",\"type\":\"no_providers_available\",\"param\":{\"name\":\"RestrictedProvidersError\",\"message\":\"Your team has restricted access to this provider. Contact the owner of the account for more details. Providers considered: wafer\"}},\"providerMetadata\":{\"gateway\":{\"routing\":{}}}}", null, );}
fn testProcessQueuedPromptUnauthorized(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { try std.testing.expect(semantic_presentation == null); try deps.push_http_error( deps.ctx, .unauthorized, "provider rejected secret-key body", job.credential_source, );}
fn testProcessQueuedPromptUnauthorizedThenHistory(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { try testProcessQueuedPromptUnauthorized(agent, deps, semantic_presentation, lifecycle, cfg, job); const ctx: *AskContext = @ptrCast(@alignCast(deps.ctx)); const turn = try session_runtime.makeAssistantTurn(ctx.alloc, job.prompt, "retained response"); defer types.freeHistoryTurn(ctx.alloc, turn); try deps.propagate_history_turn(deps.ctx, turn);}
fn testProcessQueuedPromptToolThenUnauthorized(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = job.turn_id, .call_id = "read_1" }, .reconciles_provisional_call_id = null, .tool_name = "read_file", .activity_kind = .read, }, }); try testProcessQueuedPromptUnauthorized(agent, deps, semantic_presentation, lifecycle, cfg, job);}
fn testProcessQueuedPromptUnauthorizedThenError(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { try testProcessQueuedPromptUnauthorized(agent, deps, semantic_presentation, lifecycle, cfg, job); return error.InjectedPromptFailure;}
const DiscardProbe = struct { disposition: session_store.PristineDiscardDisposition, calls: usize = 0, borrowers_detached: bool = false,
fn run( raw: ?*anyopaque, ctx: *AskContext, loaded: *session_store.LoadedWritableSession, ) session_store.PristineDiscardDisposition { const self: *DiscardProbe = @ptrCast(@alignCast(raw.?)); self.calls += 1; self.borrowers_detached = ctx.writable == null and ctx.subagent_host == null and ctx.session.webFetchArtifactStore() == null; loaded.deinit(ctx.alloc); return self.disposition; }};
var test_initialize_session_store_calls: usize = 0;var test_image_preflight_startup_calls: usize = 0;var test_image_preflight_process_calls: usize = 0;
fn testCountImagePreflightStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { test_image_preflight_startup_calls += 1; return testPresentKeyStartup(alloc, transport, secret_store, default_model, default_agent_step_limit);}
fn testCountImagePreflightProcess(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { test_image_preflight_process_calls += 1; try testProcessQueuedPrompt(agent, deps, semantic_presentation, lifecycle, cfg, job);}
fn testProcessQueuedPromptChecksImageAuthority( agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt,) !void { try std.testing.expectEqual(@as(usize, 1), job.images.len); try std.testing.expectEqual(@as(usize, 1), job.images[0].id); try std.testing.expectEqual(@as(usize, 1), job.authorized_image_catalog.len); try std.testing.expectEqual(@as(usize, 1), job.authorized_image_catalog[0].id); try std.testing.expectEqualStrings(job.images[0].path, job.authorized_image_catalog[0].path); try testProcessQueuedPrompt(agent, deps, semantic_presentation, lifecycle, cfg, job);}
var test_no_save_snapshot_path: ?[]u8 = null;
fn testProcessQueuedPromptCapturesNoSaveSnapshot( agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt,) !void { try std.testing.expectEqual(@as(usize, 1), job.images.len); test_no_save_snapshot_path = try std.testing.allocator.dupe( u8, job.images[0].snapshot_path.?, ); try testProcessQueuedPromptChecksImageAuthority( agent, deps, semantic_presentation, lifecycle, cfg, job, );}
fn testCountSessionStores(_: *AskContext) !void { test_initialize_session_store_calls += 1;}
fn testSkipSessionStores(_: *AskContext) !void {}
fn testFailSessionStores(_: *AskContext) !void { return error.InvalidSessionFormat;}
fn testLoadNoSkills( _: Allocator, _: []const u8, _: skill_contract.RootPolicy,) app_runtime_setup.LoadSkillsError!app_runtime_setup.LoadedSkills { return .{};}
fn testLoadTruncatedSkillsWithDiagnostic( alloc: Allocator, _: []const u8, _: skill_contract.RootPolicy,) app_runtime_setup.LoadSkillsError!app_runtime_setup.LoadedSkills { const skills = try alloc.alloc(skill_runtime.Skill, 1); errdefer alloc.free(skills); const name = try alloc.dupe(u8, "visible"); errdefer alloc.free(name); const description = try alloc.dupe(u8, "visible skill"); errdefer alloc.free(description); const path = try alloc.dupe(u8, "/tmp/visible/SKILL.md"); errdefer alloc.free(path); skills[0] = .{ .name = name, .description = description, .path = path, .source = .workspace_shared, };
const diagnostics = try alloc.alloc(skill_runtime.SkillDiagnostic, 1); errdefer alloc.free(diagnostics); const diagnostic_path = try alloc.dupe(u8, "/tmp/bad-skill/SKILL.md"); errdefer alloc.free(diagnostic_path); diagnostics[0] = .{ .path = diagnostic_path, .source = .workspace_shared, .scope = .candidate, .cause = .{ .invalid_metadata = .missing_name }, };
return .{ .skills = skills, .diagnostics = diagnostics, };}
fn testPresentKeyTruncatedSkillCatalogStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = try testPresentKeyNoContextStartup(alloc, transport, secret_store, default_model, default_agent_step_limit); state.context_limits.skill_catalog_bytes = .{ .value = .{ .bytes = 0 }, .source = .command_line, }; return state;}
fn testNoProjectContext(_: Allocator, _: context_contract.InitialContextInput) context_contract.ProviderError!context_contract.ProviderContext { return .{};}
fn testNoStaticContext(_: context_contract.StaticContextInput, _: Allocator, _: *std.ArrayList(ChatMessage)) context_contract.ProviderError!void {}
fn testNoTransientContext(_: context_contract.TransientContextInput, _: Allocator, _: *std.ArrayList(ChatMessage)) context_contract.ProviderError!void {}
const test_no_context_registry = context_contract.Registry{ .default_provider = .{ .id = "test.no_context", .gather_project_context_fn = testNoProjectContext, .select_applicable_project_context_fn = context_contract.selectNoApplicableProjectContext, .append_static_fn = testNoStaticContext, .append_transient_fn = testNoTransientContext,} };
var test_gather_project_context_calls: usize = 0;
fn testCountProjectContext(_: Allocator, _: context_contract.InitialContextInput) context_contract.ProviderError!context_contract.ProviderContext { test_gather_project_context_calls += 1; return .{};}
const test_count_context_registry = context_contract.Registry{ .default_provider = .{ .id = "test.count_context", .gather_project_context_fn = testCountProjectContext, .select_applicable_project_context_fn = context_contract.selectNoApplicableProjectContext, .append_static_fn = testNoStaticContext, .append_transient_fn = testNoTransientContext,} };
const test_registry_static_context = "<project-files>\nexact registry bytes\n</project-files>";const test_registry_transient_context = "transient registry bytes";
const TestContextRegistryFixture = struct { const delivered_source = "/workspace/AGENTS.md"; const evaluated_endpoint = "/workspace";
var gather_calls: usize = 0; var process_calls: usize = 0; var gather_error: ?context_contract.ProviderError = null; var expected_target_paths: []const []const u8 = &.{}; var targets_match: bool = false; var expected_static_context: ?[]const u8 = null; var expected_gather_calls: usize = 0; var transient_permission_mode: ?PermissionMode = null;
fn reset(expected_static: ?[]const u8, expected_calls: usize) void { gather_calls = 0; process_calls = 0; gather_error = null; expected_target_paths = &.{}; targets_match = false; expected_static_context = expected_static; expected_gather_calls = expected_calls; transient_permission_mode = null; }
fn gather(alloc: Allocator, input: context_contract.InitialContextInput) context_contract.ProviderError!context_contract.ProviderContext { gather_calls += 1; targets_match = input.targets.len == expected_target_paths.len; if (targets_match) { for (input.targets, expected_target_paths) |target, expected_path| { if (target.kind != .file or !std.mem.eql(u8, target.path, expected_path)) { targets_match = false; break; } } } if (gather_error) |err| return err; var result: context_contract.ProviderContext = .{ .content = try alloc.dupe(u8, test_registry_static_context), }; errdefer result.deinit(alloc); result.delivered_sources = try dupeStrings(alloc, &.{delivered_source}); result.evaluated_endpoints = try dupeStrings(alloc, &.{evaluated_endpoint}); return result; }
fn dupeStrings(alloc: Allocator, values: []const []const u8) Allocator.Error![][]u8 { const owned = try alloc.alloc([]u8, values.len); var initialized: usize = 0; errdefer { for (owned[0..initialized]) |value| alloc.free(value); alloc.free(owned); } for (values, 0..) |value, index| { owned[index] = try alloc.dupe(u8, value); initialized += 1; } return owned; }
fn appendStatic(input: context_contract.StaticContextInput, alloc: Allocator, messages: *std.ArrayList(ChatMessage)) context_contract.ProviderError!void { if (input.project_context.len > 0) { try messages.append(alloc, .{ .role = .system, .content = input.project_context }); } }
fn appendTransient(input: context_contract.TransientContextInput, alloc: Allocator, messages: *std.ArrayList(ChatMessage)) context_contract.ProviderError!void { transient_permission_mode = input.permission_mode; try messages.append(alloc, .{ .role = .system, .content = test_registry_transient_context }); }
fn process(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { process_calls += 1; try std.testing.expect(semantic_presentation == null); try std.testing.expectEqual(expected_gather_calls, gather_calls); if (expected_gather_calls > 0) try std.testing.expect(targets_match); const ctx: *AskContext = @ptrCast(@alignCast(deps.ctx)); if (expected_static_context != null) { const contribution = ctx.context_snapshot.contribution orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("test.cli_context", contribution.provider_id); } else { try std.testing.expect(ctx.context_snapshot.contribution == null); } const tool_context = ctx.toolContext(); try std.testing.expectEqualStrings("test.cli_context", tool_context.context_registry.defaultProvider().id); if (expected_static_context != null) { try std.testing.expectEqual(@as(usize, 1), job.context_snapshot.delivered_sources.len); try std.testing.expectEqualStrings(delivered_source, job.context_snapshot.delivered_sources[0]); try std.testing.expectEqual(@as(usize, 1), job.context_snapshot.evaluated_endpoints.len); try std.testing.expectEqualStrings(evaluated_endpoint, job.context_snapshot.evaluated_endpoints[0]); } else { try std.testing.expectEqual(@as(usize, 0), job.context_snapshot.delivered_sources.len); try std.testing.expectEqual(@as(usize, 0), job.context_snapshot.evaluated_endpoints.len); }
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var messages: std.ArrayList(ChatMessage) = .empty; defer messages.deinit(arena);
const append_static = deps.append_static_context orelse return error.TestExpectedEqual; try append_static(deps.ctx, arena, null, &messages); try deps.append_runtime_context(deps.ctx, arena, &messages); try std.testing.expectEqual( ctx.permission_mode, transient_permission_mode orelse return error.TestExpectedEqual, );
if (expected_static_context) |expected_static| { try std.testing.expectEqual(@as(usize, 3), messages.items.len); try std.testing.expectEqualStrings(expected_static, messages.items[0].content.?); try std.testing.expect(std.mem.find(u8, messages.items[1].content.?, "<mcp_servers>") != null); try std.testing.expectEqualStrings(test_registry_transient_context, messages.items[2].content.?); } else { try std.testing.expectEqual(@as(usize, 2), messages.items.len); try std.testing.expect(std.mem.find(u8, messages.items[0].content.?, "<mcp_servers>") != null); try std.testing.expectEqualStrings(test_registry_transient_context, messages.items[1].content.?); }
try testPushAssistantText(deps, "assistant text"); }};
const test_cli_context_registry = context_contract.Registry{ .default_provider = .{ .id = "test.cli_context", .gather_project_context_fn = TestContextRegistryFixture.gather, .select_applicable_project_context_fn = context_contract.selectNoApplicableProjectContext, .append_static_fn = TestContextRegistryFixture.appendStatic, .append_transient_fn = TestContextRegistryFixture.appendTransient,} };
fn testPresentKeyNoContextStartup(alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize) !app_lifecycle.StartupState { var state = try testPresentKeyStartup(alloc, transport, secret_store, default_model, default_agent_step_limit); state.context_enabled = false; return state;}
fn testNoMcpRuntime(_: Allocator, _: []const u8, _: mcp_elicitation.Capabilities) !?*mcp_runtime.McpRuntime { return null;}
fn testPromptRunDeps(stdout_capture: *TestCapture, stderr_capture: *TestCapture, load_startup_state: LoadStartupStateFn) RunDeps { return .{ .stdout_ctx = stdout_capture, .stderr_ctx = stderr_capture, .write_stdout = TestCapture.write, .write_stderr = TestCapture.write, .load_startup_state = load_startup_state, .initialize_session_stores = testSkipSessionStores, .load_skills = testLoadNoSkills, .context_registry = test_no_context_registry, .tool_set = builtin_tools.advertisement_set, .load_mcp_runtime = testNoMcpRuntime, .process_queued_prompt = testProcessQueuedPrompt, };}
fn testPromptRunDepsWithProcess(stdout_capture: *TestCapture, stderr_capture: *TestCapture, process: ProcessQueuedPromptFn) RunDeps { var deps = testPromptRunDeps(stdout_capture, stderr_capture, testPresentKeyStartup); deps.process_queued_prompt = process; return deps;}
fn testPermissionRuleSet(alloc: Allocator, permission: []const u8, pattern: []const u8, action: types.PermissionAction) !types.PermissionRuleSet { var rules = types.PermissionRuleSet{ .rules = try alloc.alloc(types.PermissionRule, 1), }; errdefer alloc.free(rules.rules);
const owned_permission = try alloc.dupe(u8, permission); errdefer alloc.free(owned_permission);
const owned_pattern = try alloc.dupe(u8, pattern); rules.rules[0] = .{ .permission = owned_permission, .pattern = owned_pattern, .action = action, }; return rules;}
test "CLI lifecycle action preserves dynamic MCP availability boundaries" { const Fixture = struct { calls: usize = 0, available: bool = false,
fn hasTool(raw_ctx: *anyopaque, _: []const u8, _: tool_mcp_runtime.Access) bool { const self: *@This() = @ptrCast(@alignCast(raw_ctx)); self.calls += 1; return self.available; } }; const alloc = std.testing.allocator; const advertised = [_][]const u8{"mcp_lookup"}; var fixture = Fixture{};
const missing = dynamicMcpToolAvailable(builtin_tools.registry, "mcp_lookup", &advertised, @ptrCast(&fixture), Fixture.hasTool, .unrestricted); try std.testing.expect(!missing); try std.testing.expectEqual(@as(usize, 1), fixture.calls); const missing_label = try tool_presentation.formatPlainAction(alloc, .{ .tool_registry = builtin_tools.registry, .call = .{ .id = "missing", .name = "mcp_lookup", .arguments_json = "{}" }, .is_available_dynamic_mcp_tool = missing, }); defer alloc.free(missing_label); try std.testing.expectEqualStrings("Working: mcp_lookup", missing_label);
const builtin = dynamicMcpToolAvailable(builtin_tools.registry, "terminal", &.{"shell"}, @ptrCast(&fixture), Fixture.hasTool, .unrestricted); try std.testing.expect(!builtin); try std.testing.expectEqual(@as(usize, 1), fixture.calls);}
test "CLI lifecycle dynamic MCP capability preserves its callback context" { var ctx: AskContext = undefined; var mcp = mcp_runtime.McpRuntime.init(std.testing.allocator); defer mcp.deinit(); ctx.mcp = &mcp;
const capability = lifecycleDynamicMcpAvailability(&ctx);
try std.testing.expect(capability.context != null); try std.testing.expectEqual( @intFromPtr(&ctx), @intFromPtr(capability.context.?), ); try std.testing.expect(capability.has_tool == mcpHasTool);}
test "Ask MCP adapters revalidate scoped authority before catalog access" { const alloc = std.testing.allocator; const ViewFactory = struct { fn init(factory_alloc: Allocator, runtime_generation: u64) !mcp_access_policy.View { const owner_id = try factory_alloc.dupe(u8, "child"); errdefer factory_alloc.free(owner_id); const parent_id = try factory_alloc.dupe(u8, "parent"); errdefer factory_alloc.free(parent_id); const servers = try factory_alloc.alloc(mcp_access_policy.ServerIdentity, 1); errdefer factory_alloc.free(servers); servers[0] = .{ .name = try factory_alloc.dupe(u8, "fixture"), .source = .profile, .scope = .profile, .connection_generation = 1, .catalog_generation = 1, .auth_generation = 0, }; errdefer servers[0].deinit(factory_alloc); const tools = try factory_alloc.alloc(mcp_access_policy.ToolIdentity, 1); errdefer factory_alloc.free(tools); const tool_name = try factory_alloc.dupe(u8, "mcp_fixture_echo"); errdefer factory_alloc.free(tool_name); const tool_server_name = try factory_alloc.dupe(u8, "fixture"); errdefer factory_alloc.free(tool_server_name); tools[0] = .{ .name = tool_name, .server_name = tool_server_name, }; return .{ .runtime_generation = runtime_generation, .owner_id = owner_id, .parent_id = parent_id, .features_visible = false, .servers = servers, .tools = tools, }; } }; const LiveProvider = struct { view: *const mcp_access_policy.View, calls: usize = 0,
fn resolve(raw: *anyopaque, provider_alloc: Allocator) !tool_mcp_runtime.ResolvedLiveView { const self: *@This() = @ptrCast(@alignCast(raw)); self.calls += 1; const view = try self.view.clone(provider_alloc); return .{ .authority_generation = mcp_access_policy.authorityGeneration(view), .view = view, }; } };
var runtime = mcp_runtime.McpRuntime.init(alloc); defer runtime.deinit(); var ctx: AskContext = undefined; ctx.mcp = &runtime; var captured = try ViewFactory.init(alloc, runtime.generation); defer captured.deinit(alloc); var live = try captured.clone(alloc); defer live.deinit(alloc); live.tools[0].deinit(alloc); alloc.free(live.tools); live.tools = try alloc.alloc(mcp_access_policy.ToolIdentity, 0); var provider = LiveProvider{ .view = &live }; const access = tool_mcp_runtime.Access{ .scoped = .{ .captured = &captured, .admission_authority_generation = mcp_access_policy.authorityGeneration(captured), .live = .{ .context = @ptrCast(&provider), .resolve_fn = LiveProvider.resolve, }, } };
try std.testing.expect(!mcpHasTool(@ptrCast(&ctx), "mcp_fixture_echo", access)); try std.testing.expectEqual(@as(usize, 1), provider.calls); provider.calls = 0; try std.testing.expectError( error.McpAuthorityChanged, mcpValidateTool(@ptrCast(&ctx), alloc, "mcp_fixture_echo", "{}", access), ); try std.testing.expectEqual(@as(usize, 1), provider.calls); provider.calls = 0; try std.testing.expectError( error.McpAuthorityChanged, mcpToolSchemaJson(@ptrCast(&ctx), alloc, "mcp_fixture_echo", .{}, .{}, access, null), ); try std.testing.expectEqual(@as(usize, 1), provider.calls);}
test "fx ask deps validate malformed registered calls" { const alloc = std.testing.allocator; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit();
const deps = agentRuntimeDeps(&ctx); try deps.finalize_turn(deps.ctx, 8, .completed, null); try std.testing.expectEqual(@as(usize, 0), ctx.worker.worker_events.items.len); const validate = deps.validate_tool_call orelse return error.TestExpectedEqual; const result = try validate(deps.ctx, alloc, .{ .id = "fetch", .name = "web_fetch", .arguments_json = "{\"url\":1}", }); defer alloc.free(result.failure); try std.testing.expectEqualStrings("web_fetch field \"url\" must be a string", result.failure);}
test "CLI prompt projection configures web search then blocks native execution" { const alloc = std.testing.allocator; const web_search_contract = @import("../tooling/web_search_contract.zig"); const ProviderState = struct { calls: usize = 0, }; const FailingWebSearchProvider = struct { fn execute( raw_ctx: ?*anyopaque, _: Allocator, _: web_search_runtime.Inputs, _: web_search_contract.ProviderRequest, _: ?web_search_contract.ProgressFn, _: ?*anyopaque, ) anyerror!web_search_contract.ProviderResponse { const state: *ProviderState = @ptrCast(@alignCast(raw_ctx orelse return error.TestWebSearchProvider)); state.calls += 1; return error.TestWebSearchProvider; } }; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit(); var provider_state = ProviderState{}; var provider = ctx.web_search_runtime.provider orelse return error.TestExpectedEqual; provider.context = @ptrCast(&provider_state); provider.execute_fn = FailingWebSearchProvider.execute; ctx.web_search_runtime = web_search_runtime.Runtime.init(.{ .provider = provider, });
ctx.web_search_runtime.configure(.{ .api_key = "stale-key", .worker_model = "stale-model", .gateway_retry_count = 99, .gateway_chat_url = "https://stale.invalid/chat", });
var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var messages: std.ArrayList(ChatMessage) = .empty; defer messages.deinit(arena); const deps = agentRuntimeDeps(&ctx); const append_static = deps.append_static_context orelse return error.TestExpectedEqual; try append_static(deps.ctx, arena, null, &messages); try deps.append_runtime_context(deps.ctx, arena, &messages);
try std.testing.expectEqualStrings("stale-key", ctx.web_search_runtime.api_key);
const validate = deps.validate_tool_call orelse return error.TestExpectedEqual; const validation = try validate(deps.ctx, arena, .{ .id = "search", .name = "web_search", .arguments_json = "{\"query\":\"x\"}", }); try std.testing.expectEqualStrings("web_search field \"query\" must contain at least two characters", validation.failure); try std.testing.expectEqualStrings(ctx.api_key, ctx.web_search_runtime.api_key); try std.testing.expectEqualStrings(ctx.model, ctx.web_search_runtime.worker_model); try std.testing.expectEqual(ctx.cfg.gateway_retry_count, ctx.web_search_runtime.gateway_retry_count); try std.testing.expectEqualStrings(ctx.cfg.gateway_chat_url, ctx.web_search_runtime.gateway_chat_url);
const execute = deps.execute_tool_call; const execution = try execute(deps.ctx, .{ .call_allocator = arena, .result_allocator = arena, .call = .{ .id = "search-execute", .name = "web_search", .arguments_json = "{\"query\":\"current Zig release\"}", }, .authority = .ordinary, .session_grants = &.{}, .advertised_dynamic_tool_names = &.{}, .max_tool_result_bytes = ctx.max_tool_result_bytes, }); try std.testing.expectEqualStrings(ctx.api_key, ctx.web_search_runtime.api_key); try std.testing.expectEqualStrings(ctx.model, ctx.web_search_runtime.worker_model); try std.testing.expectEqual(ctx.cfg.gateway_retry_count, ctx.web_search_runtime.gateway_retry_count); try std.testing.expectEqualStrings(ctx.cfg.gateway_chat_url, ctx.web_search_runtime.gateway_chat_url); try std.testing.expectEqual(.failure, execution.status); try std.testing.expectEqual(@as(usize, 0), provider_state.calls);}
test "fx ask publishes a complete refreshed credential before later consumers" { const alloc = std.testing.allocator; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.api_key = "stale-token"; ctx.credential_source = .fx_login; ctx.gateway_team = "team_123";
var refreshed = credentials.Credential{ .token = try alloc.dupe(u8, "fresh-token"), .source = .fx_login, .team_id = try alloc.dupe(u8, "team_123"), .refresh_after_ms = 100, }; defer refreshed.deinit(alloc); const worker_token = try adoptRefreshedAskCredential(&ctx, alloc, &refreshed); defer secret.zeroAndFree(alloc, worker_token);
try std.testing.expectEqualStrings("fresh-token", worker_token); try std.testing.expectEqualStrings("fresh-token", ctx.api_key); try std.testing.expectEqual(credentials.Source.fx_login, ctx.credential_source.?); try std.testing.expectEqualStrings("team_123", ctx.gateway_team.?); try std.testing.expectEqualStrings( "fresh-token", ctx.model_catalog_access.authorizationCredential().?, );}
test "fx ask ChatGPT route disables Gateway-backed auxiliary providers" { const alloc = std.testing.allocator; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.api_key = "chatgpt-secret"; ctx.credential_source = .chatgpt_subscription; ctx.provider = .codex; ctx.model = "gpt-5.4";
const tool_ctx = ctx.toolContext(); try std.testing.expect(tool_ctx.web_search_backend == null); try std.testing.expect(!tool_ctx.auto_classifier.enabled()); try std.testing.expect(tool_ctx.permission_reviewer_provider == null);}
test "fx ask finalization fails every failed turn" { const cases = [_]struct { outcome: types.TurnPresentationOutcome, disposition: ?types.ProviderCompletionDisposition, expected_failed: bool, }{ .{ .outcome = .failed, .disposition = .length_limited, .expected_failed = true }, .{ .outcome = .completed, .disposition = .length_limited, .expected_failed = false }, .{ .outcome = .failed, .disposition = null, .expected_failed = true }, };
for (cases) |case| { const alloc = std.testing.allocator; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit(); const deps = agentRuntimeDeps(&ctx);
try deps.finalize_turn(deps.ctx, 8, case.outcome, case.disposition);
try std.testing.expectEqual(case.expected_failed, ctx.failed); try std.testing.expectEqual(@as(usize, 0), ctx.worker.worker_events.items.len); }}
test "fx ask deps reject malformed native web_search calls" { const alloc = std.testing.allocator; var stdout_capture = TestCapture{}; defer stdout_capture.deinit(alloc); var stderr_capture = TestCapture{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit();
const deps = agentRuntimeDeps(&ctx); const validate = deps.validate_tool_call orelse return error.TestExpectedEqual; const result = try validate(deps.ctx, alloc, .{ .id = "search", .name = "web_search", .arguments_json = "{\"query\":\"x\"}", }); defer alloc.free(result.failure); try std.testing.expectEqualStrings("web_search field \"query\" must contain at least two characters", result.failure);}
test "parse options preserves active ask flags and operands" { var options = try parseOptionsWithStdin(std.testing.allocator, &.{ "--auto", "--image", "a.png", "--system", "first", "--system", "second", "--json", "--prompt-permissions", "--quiet", "--verbose", "--no-save", "--no-color", "--timeout", "123", "hello", "world", }, .tty); defer options.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(?PermissionMode, .auto), options.permission_override); try std.testing.expect(options.json_output); try std.testing.expect(options.prompt_permissions); try std.testing.expect(options.quiet); try std.testing.expect(options.verbose); try std.testing.expect(options.no_save); try std.testing.expect(options.no_color); try std.testing.expectEqual(@as(?usize, 123 * std.time.ms_per_s), options.timeout_ms); try std.testing.expectEqualStrings("second", options.system_prompt_override.?); try std.testing.expectEqual(@as(usize, 1), options.image_paths.items.len); try std.testing.expectEqualStrings("a.png", options.image_paths.items[0]); try std.testing.expectEqual(@as(usize, 0), options.images.items.len); try std.testing.expectEqualStrings("hello world", options.prompt);}
test "parse options accepts full access aliases and rejects permission flag conflicts" { const alloc = std.testing.allocator; const aliases = [_][:0]const u8{ "--full-access", "--yolo" }; for (aliases) |alias| { var options = try parseOptionsWithStdin(alloc, &.{ alias, "hello" }, .tty); defer options.deinit(alloc); try std.testing.expectEqual(@as(?PermissionMode, .yolo), options.permission_override); try std.testing.expectEqualStrings("hello", options.prompt); }
const permission_flags = [_][:0]const u8{ "--auto", "--full-access", "--yolo" }; for (permission_flags) |first| { for (permission_flags) |second| { try std.testing.expectError( error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{ first, second, "hello" }, .tty), ); } }}
test "parse options preserves full access aliases after the delimiter as prompt text" { const alloc = std.testing.allocator; var literal = try parseOptionsWithStdin(alloc, &.{ "--", "--full-access", "--yolo", "--auto" }, .tty); defer literal.deinit(alloc); try std.testing.expectEqual(@as(?PermissionMode, null), literal.permission_override); try std.testing.expectEqualStrings("--full-access --yolo --auto", literal.prompt);
const cases = [_]struct { flag: [:0]const u8, mode: PermissionMode, }{ .{ .flag = "--auto", .mode = .auto }, .{ .flag = "--full-access", .mode = .yolo }, .{ .flag = "--yolo", .mode = .yolo }, }; for (cases) |case| { var options = try parseOptionsWithStdin(alloc, &.{ case.flag, "--", "--full-access", "--yolo", "--auto" }, .tty); defer options.deinit(alloc); try std.testing.expectEqual(@as(?PermissionMode, case.mode), options.permission_override); try std.testing.expectEqualStrings("--full-access --yolo --auto", options.prompt); }}
test "parse options preserves model effort and fast overrides" { const alloc = std.testing.allocator; var options = try parseOptionsWithStdin(alloc, &.{ "--model", "provider/override-model", "--effort", "high", "--fast", "hello", }, .tty); defer options.deinit(alloc);
try std.testing.expectEqualStrings("provider/override-model", options.model_override.?); try std.testing.expect(options.effort_override.?.eql(types.ReasoningEffort.literal("high"))); try std.testing.expectEqual(@as(?bool, true), options.fast_override); try std.testing.expectEqualStrings("hello", options.prompt);
var off = try parseOptionsWithStdin(alloc, &.{ "--no-fast", "hello" }, .tty); defer off.deinit(alloc); try std.testing.expectEqual(@as(?bool, false), off.fast_override);
var defaulted = try parseOptionsWithStdin(alloc, &.{"hello"}, .tty); defer defaulted.deinit(alloc); try std.testing.expectEqual(@as(?[]u8, null), defaulted.model_override); try std.testing.expectEqual(@as(?types.ReasoningEffort, null), defaulted.effort_override); try std.testing.expectEqual(@as(?bool, null), defaulted.fast_override);
var last_model = try parseOptionsWithStdin(alloc, &.{ "--model", "first/model", "--model", "second/model", "hello" }, .tty); defer last_model.deinit(alloc); try std.testing.expectEqualStrings("second/model", last_model.model_override.?);}
test "parse options rejects invalid model effort and fast flag forms" { const alloc = std.testing.allocator; try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{"--model"}, .tty)); try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{ "--model", " ", "hello" }, .tty)); try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{"--effort"}, .tty)); try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{ "--effort", "not an effort", "hello" }, .tty)); try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{ "--fast", "--no-fast", "hello" }, .tty)); try std.testing.expectError(error.InvalidAskArgs, parseOptionsWithStdin(alloc, &.{ "--no-fast", "--fast", "hello" }, .tty));
var literal = try parseOptionsWithStdin(alloc, &.{ "--", "--model", "--fast" }, .tty); defer literal.deinit(alloc); try std.testing.expectEqual(@as(?[]u8, null), literal.model_override); try std.testing.expectEqual(@as(?bool, null), literal.fast_override); try std.testing.expectEqualStrings("--model --fast", literal.prompt);}
test "headless yolo warning reaches stderr before acknowledgment persistence" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); TestYoloPersistence.reset(); defer TestYoloPersistence.reset(); TestYoloPersistence.warning_capture = &stderr_capture;
var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testMissingKeyStartup); deps.persist_yolo_acknowledgment = TestYoloPersistence.persist; const exit_code = try runWithDeps( alloc, &.{ "--json", "--yolo", "hello" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 1), TestYoloPersistence.calls); try std.testing.expect(TestYoloPersistence.observed_warning_before_persist); try std.testing.expect(std.mem.startsWith( u8, stderr_capture.bytes.items, permissions.yolo_warning_text ++ "\n", )); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, permissions.yolo_warning_text) == null);}
test "headless yolo warning precedes startup configuration diagnostics" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); TestYoloPersistence.reset(); defer TestYoloPersistence.reset();
var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyDiagnosticStartup, ); deps.persist_yolo_acknowledgment = TestYoloPersistence.persist; _ = try runWithDeps( alloc, &.{ "--yolo", "hello" }, testConfig(), deps, );
try std.testing.expect(std.mem.startsWith( u8, stderr_capture.bytes.items, permissions.yolo_warning_text ++ "\n" ++ "fx ask: config user: malformed_settings\n", ));}
test "headless yolo persistence is skipped when warning emission fails" { TestYoloPersistence.reset(); defer TestYoloPersistence.reset(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(std.testing.allocator); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(std.testing.allocator); var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyStartup, ); deps.stderr_ctx = null; deps.write_stderr = TestFailingStderr.write; deps.persist_yolo_acknowledgment = TestYoloPersistence.persist;
try std.testing.expectError( error.TestStderrWriteFailed, emitHeadlessYoloWarning(std.testing.allocator, .{ .output_mode = .raw, .deps = deps, }), ); try std.testing.expectEqual(@as(usize, 0), TestYoloPersistence.calls);}
test "headless yolo warning respects acknowledgment and no-color" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); TestYoloPersistence.reset(); defer TestYoloPersistence.reset();
var acknowledged_deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyAcknowledgedStartup, ); acknowledged_deps.persist_yolo_acknowledgment = TestYoloPersistence.persist; _ = try runWithDeps( alloc, &.{ "--yolo", "hello" }, testConfig(), acknowledged_deps, ); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, permissions.yolo_warning_text) == null); try std.testing.expectEqual(@as(usize, 0), TestYoloPersistence.calls);
stderr_capture.bytes.clearRetainingCapacity(); var color_deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyStartup, ); color_deps.stderr_is_tty = TestTty.yes; color_deps.persist_yolo_acknowledgment = TestYoloPersistence.persist; _ = try runWithDeps( alloc, &.{ "--yolo", "hello" }, testConfig(), color_deps, ); try std.testing.expect(std.mem.startsWith( u8, stderr_capture.bytes.items, "\x1b[38;5;252m" ++ permissions.yolo_warning_text ++ "\x1b[0m\n", ));
stderr_capture.bytes.clearRetainingCapacity(); var no_color_deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyStartup, ); no_color_deps.stderr_is_tty = TestTty.yes; no_color_deps.persist_yolo_acknowledgment = TestYoloPersistence.persist; _ = try runWithDeps( alloc, &.{ "--no-color", "--yolo", "hello" }, testConfig(), no_color_deps, ); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "\x1b[31m") == null); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, permissions.yolo_warning_text) != null);}
test "runWithDeps reports a missing image before startup after cleaning prior attachments" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var file = try tmp.dir.createFile(std.testing.io, "valid.png", .{}); defer file.close(std.testing.io); try file.writeStreamingAll(std.testing.io, "\x89PNG\r\n\x1a\nrest"); } const valid_path = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "valid.png"); defer alloc.free(valid_path); const valid_path_z = try alloc.dupeZ(u8, valid_path); defer alloc.free(valid_path_z); const missing_path_z = try alloc.dupeZ(u8, "/tmp/fx-ask-missing-image.png"); defer alloc.free(missing_path_z);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); test_image_preflight_startup_calls = 0; test_image_preflight_process_calls = 0; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testCountImagePreflightStartup); deps.process_queued_prompt = testCountImagePreflightProcess;
const exit_code = try runWithDeps( alloc, &.{ "--image", valid_path_z, "--image", missing_path_z, "describe" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 0), test_image_preflight_startup_calls); try std.testing.expectEqual(@as(usize, 0), test_image_preflight_process_calls); try std.testing.expectEqual(@as(usize, 0), stdout_capture.bytes.items.len); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, missing_path_z) != null); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "image file not found") != null);}
test "runWithDeps reports unsupported images in JSON before startup" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var file = try tmp.dir.createFile(std.testing.io, "not-image.txt", .{}); defer file.close(std.testing.io); try file.writeStreamingAll(std.testing.io, "plain text"); } const invalid_path = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "not-image.txt"); defer alloc.free(invalid_path); const invalid_path_z = try alloc.dupeZ(u8, invalid_path); defer alloc.free(invalid_path_z);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); test_image_preflight_startup_calls = 0; test_image_preflight_process_calls = 0; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testCountImagePreflightStartup); deps.process_queued_prompt = testCountImagePreflightProcess;
const exit_code = try runWithDeps( alloc, &.{ "--json", "--image", invalid_path_z, "describe" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 0), test_image_preflight_startup_calls); try std.testing.expectEqual(@as(usize, 0), test_image_preflight_process_calls); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "\"exit_code\":1") != null); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "UnsupportedImageType") != null); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, invalid_path_z) != null);}
test "runWithDeps assigns image ids and owns the authorized catalog before processing" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var file = try tmp.dir.createFile(std.testing.io, "valid.png", .{}); defer file.close(std.testing.io); try file.writeStreamingAll(std.testing.io, "\x89PNG\r\n\x1a\nrest"); } const image_path = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "valid.png"); defer alloc.free(image_path); const image_path_z = try alloc.dupeZ(u8, image_path); defer alloc.free(image_path_z);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.process_queued_prompt = testProcessQueuedPromptCapturesNoSaveSnapshot; test_no_save_snapshot_path = null; defer if (test_no_save_snapshot_path) |path| alloc.free(path);
const exit_code = try runWithDeps( alloc, &.{ "--json", "--no-save", "--image", image_path_z, "describe" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "assistant text") != null); const snapshot_path = test_no_save_snapshot_path orelse return error.TestExpectedEqual; try std.testing.expectError( error.FileNotFound, std.Io.Dir.openFileAbsolute(std.testing.io, snapshot_path, .{}), ); try std.testing.expectError( error.FileNotFound, std.Io.Dir.openDirAbsolute(std.testing.io, std.fs.path.dirname(snapshot_path).?, .{}), );}
test "preflightAskImages resolves image paths from the workspace" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var file = try tmp.dir.createFile(std.testing.io, "target.png", .{}); defer file.close(std.testing.io); try file.writeStreamingAll(std.testing.io, "\x89PNG\r\n\x1a\nrest"); } const workspace_root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(workspace_root); const expected_path = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "target.png"); defer alloc.free(expected_path);
var options = AskOptions{ .prompt = try alloc.dupe(u8, "describe") }; defer options.deinit(alloc); try options.image_paths.append(alloc, try alloc.dupe(u8, "target.png"));
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); const deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup);
try std.testing.expect(try preflightAskImages(alloc, workspace_root, &options, deps)); try std.testing.expectEqual(@as(usize, 1), options.images.items.len); try std.testing.expectEqualStrings(expected_path, options.images.items[0].path);}
test "parse options rejects unknown flags and accepts dash prompts after sentinel" { try std.testing.expectError( error.InvalidAskArgs, parseOptionsWithStdin( std.testing.allocator, &.{ "--timeout", "nope", "--not-a-flag", "prompt" }, .tty, ), );
var options = try parseOptionsWithStdin(std.testing.allocator, &.{ "--timeout", "nope", "--", "--not-a-flag", "prompt" }, .tty); defer options.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(?usize, null), options.timeout_ms); try std.testing.expectEqualStrings("--not-a-flag prompt", options.prompt);}
test "parse options reports missing operands and empty tty input as missing prompt" { try std.testing.expectError(error.MissingPrompt, parseOptionsWithStdin(std.testing.allocator, &.{"--image"}, .tty)); try std.testing.expectError(error.MissingPrompt, parseOptionsWithStdin(std.testing.allocator, &.{"--system"}, .tty)); try std.testing.expectError(error.MissingPrompt, parseOptionsWithStdin(std.testing.allocator, &.{"--timeout"}, .tty)); try std.testing.expectError(error.MissingPrompt, parseOptionsWithStdin(std.testing.allocator, &.{}, .tty));}
test "stdin prompt resource limit accepts exact bytes and rejects one over without a partial prompt" { const alloc = std.testing.allocator; var exact_reader = std.Io.Reader.fixed("12345678"); const exact = try readPromptFromReader(alloc, &exact_reader, 8); defer alloc.free(exact); try std.testing.expectEqualStrings("12345678", exact);
var over_reader = std.Io.Reader.fixed("123456789"); try std.testing.expectError( error.PromptResourceLimitExceeded, readPromptFromReader(alloc, &over_reader, 8), );
try std.testing.expectError( error.PromptResourceLimitExceeded, readPromptFromStdinSourceWithLimit(alloc, .{ .bytes = "123456789" }, 8), ); try std.testing.expectError( error.PromptInputReadFailed, readPromptFromStdinSourceWithLimit(alloc, .read_error, 8), );}
test "stdin prompt reader propagates allocation failure" { var failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); var reader = std.Io.Reader.fixed("prompt"); try std.testing.expectError( error.OutOfMemory, readPromptFromReader(failing.allocator(), &reader, 8), );}
test "stdin prompt errors keep exact structured names" { const alloc = std.testing.allocator; const overflow = try renderErrorJsonResult(alloc, "PromptResourceLimitExceeded"); defer alloc.free(overflow); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"PromptResourceLimitExceeded\"}\n", overflow, );
const read_failure = try renderErrorJsonResult(alloc, "PromptInputReadFailed"); defer alloc.free(read_failure); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"PromptInputReadFailed\"}\n", read_failure, );}
test "image preparation failure has stable text and JSON contracts" { const alloc = std.testing.allocator; try std.testing.expectEqualStrings( image_attachments.image_preparation_failed_notice, askErrorNotice(error.ImagePreparationFailed).?, ); try std.testing.expect(askErrorNotice(error.Cancelled) == null); try std.testing.expect(askErrorNotice(error.TimedOut) == null);
const json = try renderErrorJsonResult(alloc, @errorName(error.ImagePreparationFailed)); defer alloc.free(json); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"ImagePreparationFailed\"}\n", json, );}
test "unresolved image capability has actionable text and stable JSON code" { const alloc = std.testing.allocator; try std.testing.expectEqualStrings( "Unable to verify image support for this model, so the image was not sent. Try again later, choose another model, or remove the image.", askErrorNotice(error.ModelImageCapabilityUnavailable).?, );
const json = try renderErrorJsonResult( alloc, @errorName(error.ModelImageCapabilityUnavailable), ); defer alloc.free(json); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"ModelImageCapabilityUnavailable\"}\n", json, );}
test "stdin read failure has distinct text and JSON output contracts" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.stdin_source = .read_error;
try std.testing.expectEqual( @as(u8, 1), try runWithDeps(alloc, &.{}, testConfig(), deps), ); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings( "fx ask: failed to read prompt from stdin\n", stderr_capture.bytes.items, );
stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity(); try std.testing.expectEqual( @as(u8, 1), try runWithDeps(alloc, &.{"--json"}, testConfig(), deps), ); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"PromptInputReadFailed\"}\n", stdout_capture.bytes.items, ); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "runWithDeps threads timeout into ask tool context" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps( alloc, &.{ "--timeout", "1", "hello" }, testConfig(), testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptChecksTimeout), );
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqualStrings("assistant text", stdout_capture.bytes.items);}
test "cli ask admits default-safe web_search without a rule" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit(); const call = ToolCall{ .id = "search", .name = "web_search", .arguments_json = "{\"query\":\"current news\"}", };
try std.testing.expectEqual(ToolPermissionDecision.once, (try requestToolPermissionOutcome(&ctx, arena, call, .auto, &.{}, &.{})).decision);
ctx.permission_rules = try testPermissionRuleSet(alloc, "web_search", "*", .allow); try std.testing.expectEqual(ToolPermissionDecision.once, (try requestToolPermissionOutcome(&ctx, arena, call, .auto, &.{}, &.{})).decision);}
test "fx ask default user commands require configured authority or review" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit();
try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "direct", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"pwd\"}", }, .ask, &.{}, &.{}));
try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "blocked", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch blocked.txt\"}", }, .ask, &.{}, &.{}));
ctx.permission_rules = try testPermissionRuleSet(alloc, "bash", "touch *", .allow); const configured = (try requestToolPermissionOutcome(&ctx, arena, .{ .id = "configured", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch configured.txt\"}", }, .ask, &.{}, &.{})); switch ((configured.execution_authority orelse return error.TestExpectedEqual).run_command) { .direct_only => return error.TestExpectedShellAllowed, .shell_allowed => |authority| try std.testing.expectEqual(command_admission.ShellAuthorizationSource.configured_rule, authority.source), }
ctx.permission_rules.deinit(alloc); ctx.permission_rules = .{}; const automatic = try requestToolPermissionOutcome(&ctx, arena, .{ .id = "automatic", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch automatic.txt\"}", }, .auto, &.{}, &.{}); try std.testing.expectEqual(ToolPermissionDecision.deny, automatic.decision); try std.testing.expectEqual(types.ToolPermissionDenialReason.review_unavailable, automatic.denial_reason.?);}
test "fx ask automatic review observes worker cancellation" { const Provider = struct { fn review( _: ?*anyopaque, _: Allocator, input: permission_auto_classifier.ProviderInput, _: permission_auto_classifier.ReviewRequest, ) anyerror!permission_auto_classifier.ParseOutcome { const cancel_flag = input.cancel_flag orelse return .{ .invalid = .provider_context_missing }; if (cancel_flag.load(.seq_cst)) return error.Cancelled; return .{ .invalid = .provider_failed }; } };
const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var cfg = testConfig(); cfg.provider_set.gateway.permission_reviewer = .{ .review_fn = Provider.review }; var ctx = AskContext.init( alloc, cfg, testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.worker.worker_cancel_requested.store(true, .seq_cst);
const call: ToolCall = .{ .id = "cancelled-review", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch cancelled.txt\"}", }; var review_turn = TestReviewTurn.init("Create cancelled.txt.", call); try std.testing.expectError( error.Cancelled, requestToolPermissionOutcomeWithRequest(&ctx, arena_state.allocator(), call, review_turn.context(), .auto, &.{}, null, null, &.{}, null), );}
test "headless ask SIGINT sets process-lifetime cancellation storage" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
var scope = try headless_interrupt.Scope.install(true); defer scope.deinit();
_ = std.c.raise(std.posix.SIG.INT);
try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst));}
test "headless ask interrupt installation exposes a typed busy result" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const InstallResult = @TypeOf(headless_interrupt.Scope.install(true)); try std.testing.expect( std.meta.activeTag(@typeInfo(InstallResult)) == .error_union, );}
var test_previous_sigint_count = std.atomic.Value(usize).init(0);fn testPreviousSigintHandler(_: std.posix.SIG) callconv(.c) void { _ = test_previous_sigint_count.fetchAdd(1, .seq_cst);}
fn testProcessQueuedPromptRaisesSigintAndSucceeds( agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt,) !void { if (comptime supports_headless_interrupt) { _ = std.c.raise(std.posix.SIG.INT); } try testProcessQueuedPrompt( agent, deps, semantic_presentation, lifecycle, cfg, job, );}
const StartupCancellationStage = enum { after_startup_state, during_session_initialization, during_context_gather, during_mcp_load,};
var test_startup_cancellation_stage: StartupCancellationStage = .after_startup_state;var test_startup_cancellation_session_calls: usize = 0;var test_startup_cancellation_context_calls: usize = 0;var test_startup_cancellation_mcp_calls: usize = 0;var test_startup_cancellation_process_calls: usize = 0;
fn requestTestHeadlessInterrupt() void { if (comptime supports_headless_interrupt) { headless_interrupt.handle(std.posix.SIG.INT); }}
fn testLoadStartupStateWithCancellation( alloc: Allocator, transport: oauth_transport.Provider, secret_store: host.SecretStore, default_model: []const u8, default_agent_step_limit: usize,) !app_lifecycle.StartupState { const state = try testPresentKeyStartup( alloc, transport, secret_store, default_model, default_agent_step_limit, ); if (test_startup_cancellation_stage == .after_startup_state) { requestTestHeadlessInterrupt(); } return state;}
fn testInitializeSessionStoresWithCancellation(_: *AskContext) !void { test_startup_cancellation_session_calls += 1; if (test_startup_cancellation_stage == .during_session_initialization) { requestTestHeadlessInterrupt(); }}
fn testGatherContextWithCancellation( _: Allocator, _: context_contract.InitialContextInput,) context_contract.ProviderError!context_contract.ProviderContext { test_startup_cancellation_context_calls += 1; if (test_startup_cancellation_stage == .during_context_gather) { requestTestHeadlessInterrupt(); } return .{};}
const test_startup_cancellation_context_registry = context_contract.Registry{ .default_provider = .{ .id = "test.startup_cancellation", .gather_project_context_fn = testGatherContextWithCancellation, .select_applicable_project_context_fn = context_contract.selectNoApplicableProjectContext, .append_static_fn = testNoStaticContext, .append_transient_fn = testNoTransientContext,} };
fn testLoadMcpRuntimeWithCancellation(_: Allocator, _: []const u8, _: mcp_elicitation.Capabilities) !?*mcp_runtime.McpRuntime { test_startup_cancellation_mcp_calls += 1; if (test_startup_cancellation_stage == .during_mcp_load) { requestTestHeadlessInterrupt(); } return null;}
fn testProcessQueuedPromptAfterStartupCancellation( agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, semantic_presentation: ?agent_runtime.SemanticPresentationSink, lifecycle: agent_runtime.LifecycleContext, cfg: agent_runtime.Config, job: worker_runtime.QueuedPrompt,) !void { test_startup_cancellation_process_calls += 1; try testProcessQueuedPrompt( agent, deps, semantic_presentation, lifecycle, cfg, job, );}
fn testRaiseSigintAtInstallBoundary() void { _ = std.c.raise(std.posix.SIG.INT);}
fn testRaiseSigintAtTeardownBoundary() void { headless_interrupt.cancel_requested.store(false, .seq_cst); _ = std.c.raise(std.posix.SIG.INT);}
const CrossThreadSigintState = struct { request: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), sent: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), failed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),};
fn sendRequestedSigints(state: *CrossThreadSigintState) void { if (comptime !supports_headless_interrupt) return; while (!state.stop.load(.seq_cst)) { if (!state.request.swap(false, .seq_cst)) { std.Thread.yield() catch std.atomic.spinLoopHint(); continue; } std.posix.kill(std.c.getpid(), std.posix.SIG.INT) catch { state.failed.store(true, .seq_cst); }; state.sent.store(true, .seq_cst); }}
test "headless ask rejects concurrent and nested interrupt scopes without overwrite" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
var first = try headless_interrupt.Scope.install(true); defer first.deinit();
try std.testing.expectError( error.HeadlessInterruptBusy, headless_interrupt.Scope.install(true), );
const ConcurrentAttempt = struct { fn run(result: *std.atomic.Value(u8)) void { var scope = headless_interrupt.Scope.install(true) catch |err| { result.store( if (err == error.HeadlessInterruptBusy) 1 else 2, .seq_cst, ); return; }; defer scope.deinit(); result.store(3, .seq_cst); } }; var result = std.atomic.Value(u8).init(0); const thread = try std.Thread.spawn(.{}, ConcurrentAttempt.run, .{&result}); thread.join(); try std.testing.expectEqual(@as(u8, 1), result.load(.seq_cst));
_ = std.c.raise(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst));}
test "headless ask restores the exact previous SIGINT handler" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null); test_previous_sigint_count.store(0, .seq_cst);
var scope = try headless_interrupt.Scope.install(true); _ = std.c.raise(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 0), test_previous_sigint_count.load(.seq_cst)); scope.deinit();
_ = std.c.raise(std.posix.SIG.INT); try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst));}
test "headless ask redelivers consumed SIGINT after restoring the previous handler" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null); test_previous_sigint_count.store(0, .seq_cst);
var scope = try headless_interrupt.Scope.install(true); defer scope.deinit(); _ = std.c.raise(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 0), test_previous_sigint_count.load(.seq_cst));
scope.restoreAndRedeliver();
try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst));}
test "headless ask returns nonzero when the restored SIGINT handler returns" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null); test_previous_sigint_count.store(0, .seq_cst);
const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptRaisesSigintAndSucceeds, ); deps.install_headless_interrupt = true;
const exit_code = try runWithDeps( alloc, &.{ "--json", "hello" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 130), exit_code); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst));}
test "headless ask startup cancellation prevents later hooks" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null);
const cases = [_]struct { stage: StartupCancellationStage, session_calls: usize, context_calls: usize, mcp_calls: usize, }{ .{ .stage = .after_startup_state, .session_calls = 0, .context_calls = 0, .mcp_calls = 0 }, .{ .stage = .during_session_initialization, .session_calls = 1, .context_calls = 0, .mcp_calls = 0 }, .{ .stage = .during_context_gather, .session_calls = 1, .context_calls = 1, .mcp_calls = 0 }, .{ .stage = .during_mcp_load, .session_calls = 1, .context_calls = 1, .mcp_calls = 1 }, };
for (cases) |case| { test_startup_cancellation_stage = case.stage; test_startup_cancellation_session_calls = 0; test_startup_cancellation_context_calls = 0; test_startup_cancellation_mcp_calls = 0; test_startup_cancellation_process_calls = 0; test_previous_sigint_count.store(0, .seq_cst);
const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptAfterStartupCancellation, ); deps.load_startup_state = testLoadStartupStateWithCancellation; deps.initialize_session_stores = testInitializeSessionStoresWithCancellation; deps.context_registry = test_startup_cancellation_context_registry; deps.load_mcp_runtime = testLoadMcpRuntimeWithCancellation; deps.install_headless_interrupt = true;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 130), exit_code); try std.testing.expectEqual(case.session_calls, test_startup_cancellation_session_calls); try std.testing.expectEqual(case.context_calls, test_startup_cancellation_context_calls); try std.testing.expectEqual(case.mcp_calls, test_startup_cancellation_mcp_calls); try std.testing.expectEqual(@as(usize, 0), test_startup_cancellation_process_calls); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst)); }}
test "headless ask resets signal-visible cancellation state for each scope" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
var scope = try headless_interrupt.Scope.install(true); headless_interrupt.handle(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst)); scope.deinit();
headless_interrupt.handle(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst));
var next = try headless_interrupt.Scope.install(true); defer next.deinit(); try std.testing.expect(!headless_interrupt.cancel_requested.load(.seq_cst));}
test "headless ask preserves signal ordering during install and teardown" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null); test_previous_sigint_count.store(0, .seq_cst);
headless_interrupt.test_after_reset_before_install = testRaiseSigintAtInstallBoundary; defer headless_interrupt.test_after_reset_before_install = null; var scope = try headless_interrupt.Scope.install(true); try std.testing.expect(!headless_interrupt.cancel_requested.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst));
headless_interrupt.test_after_reset_before_install = null; headless_interrupt.test_after_restore = testRaiseSigintAtTeardownBoundary; defer headless_interrupt.test_after_restore = null; scope.deinit();
try std.testing.expect(!headless_interrupt.cancel_requested.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 2), test_previous_sigint_count.load(.seq_cst));}
test "headless ask cross-thread SIGINT teardown and reuse target only the active scope" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null);
var state = CrossThreadSigintState{}; const sender = try std.Thread.spawn(.{}, sendRequestedSigints, .{&state}); defer { state.stop.store(true, .seq_cst); sender.join(); }
for (0..128) |_| { var first = try headless_interrupt.Scope.install(true); state.sent.store(false, .seq_cst); state.request.store(true, .seq_cst); while (!state.sent.load(.seq_cst)) { std.Thread.yield() catch std.atomic.spinLoopHint(); } first.deinit();
var next = try headless_interrupt.Scope.install(true); for (0..4) |_| { std.Thread.yield() catch std.atomic.spinLoopHint(); } try std.testing.expect(!headless_interrupt.cancel_requested.load(.seq_cst)); _ = std.c.raise(std.posix.SIG.INT); try std.testing.expect(headless_interrupt.cancel_requested.load(.seq_cst)); next.deinit(); } try std.testing.expect(!state.failed.load(.seq_cst));}
test "disabled headless ask scope leaves the interactive SIGINT handler untouched" { if (comptime !supports_headless_interrupt) return error.SkipZigTest;
const previous_action: std.posix.Sigaction = .{ .handler = .{ .handler = testPreviousSigintHandler }, .mask = std.posix.sigemptyset(), .flags = 0, }; var original_action: std.posix.Sigaction = undefined; std.posix.sigaction(std.posix.SIG.INT, &previous_action, &original_action); defer std.posix.sigaction(std.posix.SIG.INT, &original_action, null); test_previous_sigint_count.store(0, .seq_cst);
headless_interrupt.cancel_requested.store(false, .seq_cst); var scope = try headless_interrupt.Scope.install(false); defer scope.deinit(); _ = std.c.raise(std.posix.SIG.INT);
try std.testing.expect(!headless_interrupt.cancel_requested.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 1), test_previous_sigint_count.load(.seq_cst));}
test "fx ask auto mode applies automatic clear and caution without a prompt" { const FakeClassifier = struct { calls: usize = 0, decision: permission_auto_classifier.Decision = .clear,
fn classify( raw_ctx: *anyopaque, alloc: Allocator, _: permission_auto_classifier.ReviewRequest, ) anyerror!permission_auto_classifier.ParseOutcome { const self: *@This() = @ptrCast(@alignCast(raw_ctx)); self.calls += 1; return .{ .valid = .{ .risk = if (self.decision == .clear) .low else .high, .decision = self.decision, .rationale = try alloc.dupe(u8, "test automatic review"), } }; } };
const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace"); defer ctx.deinit(); var fake = FakeClassifier{ .decision = .clear }; ctx.auto_classifier = permission_auto_classifier.Classifier.withOverride( @ptrCast(&fake), FakeClassifier.classify, );
const direct_call: ToolCall = .{ .id = "direct", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"pwd\"}", }; var direct_review = TestReviewTurn.init("Inspect the workspace.", direct_call); const direct = try requestToolPermissionOutcomeWithRequest(&ctx, arena, direct_call, direct_review.context(), .auto, &.{}, null, null, &.{}, null); try std.testing.expectEqual( command_admission.ShellAuthorizationSource.auto_classifier, direct.execution_authority.?.run_command.shell_allowed.source, ); try std.testing.expectEqual(@as(usize, 1), fake.calls);
const accepted_call: ToolCall = .{ .id = "accepted", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch accepted.txt\"}", }; var accepted_review = TestReviewTurn.init("Create accepted.txt.", accepted_call); const accepted = try requestToolPermissionOutcomeWithRequest(&ctx, arena, accepted_call, accepted_review.context(), .auto, &.{}, null, null, &.{}, null); switch ((accepted.execution_authority orelse return error.TestExpectedEqual).run_command) { .direct_only => return error.TestExpectedShellAllowed, .shell_allowed => |authority| try std.testing.expectEqual( command_admission.ShellAuthorizationSource.auto_classifier, authority.source, ), } try std.testing.expectEqual(@as(usize, 2), fake.calls);
fake.decision = .caution; const check_call: ToolCall = .{ .id = "check", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch check.txt\"}", }; var check_review = TestReviewTurn.init("Check this command.", check_call); const blocked = try requestToolPermissionOutcomeWithRequest(&ctx, arena, check_call, check_review.context(), .auto, &.{}, null, null, &.{}, null); try std.testing.expectEqual(ToolPermissionDecision.deny, blocked.decision); try std.testing.expectEqual(types.ToolPermissionDenialReason.review_caution, blocked.denial_reason.?); try std.testing.expectEqual(@as(usize, 3), fake.calls); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "fx ask terminal permission prompt approves and denies run_command" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var prompt = TestPermissionPrompt{ .result = .approve }; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt_ctx = @ptrCast(&prompt); deps.permission_approval_prompt = TestPermissionPrompt.prompt; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit();
const approved = (try requestToolPermissionOutcome(&ctx, arena, .{ .id = "approved", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch approved.txt\"}", }, .ask, &.{}, &.{})); switch ((approved.execution_authority orelse return error.TestExpectedEqual).run_command) { .direct_only => return error.TestExpectedShellAllowed, .shell_allowed => |authority| try std.testing.expectEqual( command_admission.ShellAuthorizationSource.interactive_once, authority.source, ), } try std.testing.expectEqual(@as(usize, 1), prompt.calls); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "Approve? [y/N]") != null); try std.testing.expect(prompt.label != null);
stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity(); prompt.result = .deny;
const denied = try requestToolPermissionOutcome(&ctx, arena, .{ .id = "denied", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch denied.txt\"}", }, .ask, &.{}, &.{}); try std.testing.expectEqual(ToolPermissionDecision.deny, denied.decision); try std.testing.expectEqual(types.ToolPermissionDenialReason.user_denied, denied.denial_reason.?); try std.testing.expect(denied.execution_authority == null); try std.testing.expectEqual(@as(usize, 2), prompt.calls); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "Approve? [y/N]") != null); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);}
test "fx ask permission attention fires once after a prompt is published" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var prompt = TestPermissionPrompt{ .result = .approve }; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt_ctx = @ptrCast(&prompt); deps.permission_approval_prompt = TestPermissionPrompt.prompt; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit(); ctx.active_turn_id = 42; var capture = AskAttentionCapture{}; try ctx.lifecycle_runtime.registerAttentionRequired(.{ .name = "capture", .ctx = &capture, .run = AskAttentionCapture.run, }); ctx.lifecycle_view = ctx.lifecycle_runtime.freeze();
_ = try requestToolPermissionOutcome(&ctx, arena, .{ .id = "attention", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch attention.txt\"}", }, .ask, &.{}, &.{});
try std.testing.expectEqual(@as(usize, 1), capture.calls); try std.testing.expectEqual(@as(u64, 42), capture.turn_id); try std.testing.expectEqual(@as(?hooks.AttentionKind, .permission), capture.kind);
prompt.result = .unavailable; try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "unavailable", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch unavailable.txt\"}", }, .ask, &.{}, &.{})); try std.testing.expectEqual(@as(usize, 1), capture.calls);}
test "fx ask bell writes only to TTY stderr" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.stderr_is_tty = TestTty.yes; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit();
emitAskNotificationBell(&ctx); try std.testing.expectEqualStrings("\x07", stderr_capture.bytes.items); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);
stderr_capture.bytes.clearRetainingCapacity(); ctx.deps.stderr_is_tty = TestTty.no; emitAskNotificationBell(&ctx); try std.testing.expectEqualStrings("", stderr_capture.bytes.items); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);}
test "fx ask registers notification handlers only when configured" { var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(std.testing.allocator); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(std.testing.allocator); var disabled = AskContext.init( std.testing.allocator, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer disabled.deinit(); try disabled.configureNotifications(false, false); try std.testing.expect(!disabled.lifecycle_view.hasPostTurnEnd()); try std.testing.expect(!disabled.lifecycle_view.hasAttentionRequired());
var enabled = AskContext.init( std.testing.allocator, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer enabled.deinit(); try enabled.configureNotifications(true, true);
try std.testing.expect(enabled.lifecycle_view.hasPostTurnEnd()); try std.testing.expect(enabled.lifecycle_view.hasAttentionRequired());}
test "fx ask captured and quiet permission paths bypass terminal prompt" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var prompt = TestPermissionPrompt{ .result = .approve }; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt_ctx = @ptrCast(&prompt); deps.permission_approval_prompt = TestPermissionPrompt.prompt; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit();
ctx.output_mode = .json; try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "captured", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch captured.txt\"}", }, .ask, &.{}, &.{})); try std.testing.expectEqual(@as(usize, 0), prompt.calls); try std.testing.expectEqual(@as(usize, 1), ctx.tool_call_records.items.len); try std.testing.expectEqualStrings("shell", ctx.tool_call_records.items[0].name); try std.testing.expectEqualStrings("error", ctx.tool_call_records.items[0].status); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "noninteractive_permission_prompt_unavailable") != null);
stderr_capture.bytes.clearRetainingCapacity(); ctx.output_mode = .quiet; try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "quiet", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch quiet.txt\"}", }, .ask, &.{}, &.{})); try std.testing.expectEqual(@as(usize, 0), prompt.calls); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "noninteractive_permission_prompt_unavailable") != null);}
test "fx ask permission prompt policy requires explicit captured-mode opt in and TTY stdin" { const Case = struct { output_mode: OutputMode, prompt_permissions: bool, stdin_is_tty: bool, expected: bool, }; const cases = [_]Case{ .{ .output_mode = .raw, .prompt_permissions = false, .stdin_is_tty = false, .expected = true }, .{ .output_mode = .terminal, .prompt_permissions = false, .stdin_is_tty = false, .expected = true }, .{ .output_mode = .terminal_no_color, .prompt_permissions = false, .stdin_is_tty = false, .expected = true }, .{ .output_mode = .json, .prompt_permissions = false, .stdin_is_tty = true, .expected = false }, .{ .output_mode = .quiet, .prompt_permissions = false, .stdin_is_tty = true, .expected = false }, .{ .output_mode = .json, .prompt_permissions = true, .stdin_is_tty = false, .expected = false }, .{ .output_mode = .quiet, .prompt_permissions = true, .stdin_is_tty = false, .expected = false }, .{ .output_mode = .json, .prompt_permissions = true, .stdin_is_tty = true, .expected = true }, .{ .output_mode = .quiet, .prompt_permissions = true, .stdin_is_tty = true, .expected = true }, };
for (cases) |case| { try std.testing.expectEqual( case.expected, cliPermissionPromptAllowed( case.output_mode, case.prompt_permissions, case.stdin_is_tty, ), ); }}
test "fx ask captured permission prompt opt in uses the existing prompter" { const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var prompt = TestPermissionPrompt{ .result = .approve }; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt_ctx = @ptrCast(&prompt); deps.permission_approval_prompt = TestPermissionPrompt.prompt; deps.stdin_is_tty = TestTty.yes; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit(); ctx.prompt_permissions = true;
ctx.output_mode = .json; const approved = try requestToolPermissionOutcome(&ctx, arena, .{ .id = "captured-approved", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch captured-approved.txt\"}", }, .ask, &.{}, &.{}); try std.testing.expectEqual(ToolPermissionDecision.once, approved.decision); try std.testing.expectEqual(@as(usize, 1), prompt.calls); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "Approve? [y/N]") != null); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);
prompt.result = .deny; ctx.output_mode = .quiet; const denied = try requestToolPermissionOutcome(&ctx, arena, .{ .id = "quiet-denied", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch quiet-denied.txt\"}", }, .ask, &.{}, &.{}); try std.testing.expectEqual(ToolPermissionDecision.deny, denied.decision); try std.testing.expectEqual(@as(usize, 2), prompt.calls);
ctx.deps.stdin_is_tty = TestTty.no; try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome(&ctx, arena, .{ .id = "quiet-non-tty", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch quiet-non-tty.txt\"}", }, .ask, &.{}, &.{})); try std.testing.expectEqual(@as(usize, 2), prompt.calls);}
test "fx ask terminal permission prompt propagates prompt hook errors" { const FailingPrompt = struct { fn prompt( _: ?*anyopaque, _: ?*anyopaque, _: WriteFn, _: []const u8, _: ?*anyopaque, _: NotifyAttentionFn, ) !PermissionApprovalPromptResult { return error.PromptFailure; } };
const alloc = std.testing.allocator; var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt = FailingPrompt.prompt; var ctx = AskContext.init(alloc, testConfig(), deps, "/tmp/workspace"); defer ctx.deinit();
try std.testing.expectError(error.PromptFailure, requestToolPermissionOutcome(&ctx, arena, .{ .id = "prompt-failure", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch prompt-failure.txt\"}", }, .ask, &.{}, &.{})); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "noninteractive_permission_prompt_unavailable") == null);}
test "fx ask prepared file mutation callback preserves terminal permission prompt" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); try tmp.dir.createDirPath(io_mod.getIo(), "external"); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace); const external = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "external"); defer alloc.free(external);
var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var prompt = TestPermissionPrompt{ .result = .approve }; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.permission_approval_prompt_ctx = @ptrCast(&prompt); deps.permission_approval_prompt = TestPermissionPrompt.prompt; var ctx = AskContext.init(alloc, testConfig(), deps, workspace); defer ctx.deinit();
const target_path = try std.fs.path.join(arena, &.{ external, "desktop-test.txt" }); const call: ToolCall = .{ .id = "external-write", .name = "write_file", .arguments_json = try std.fmt.allocPrint( arena, "{{\"path\":\"{s}\",\"content\":\"hello\\n\"}}", .{target_path}, ), }; var prepared = switch (try tool_admission.prepareFileMutationCall(arena, call, .{ .tool_registry = ctx.toolRegistry(), .workspace_root = workspace, })) { .tool_failure => return error.TestExpectedPreparedFileMutation, .prepared => |value| value, }; defer prepared.deinit(arena); const runtime_deps = agentRuntimeDeps(&ctx); const callback = runtime_deps.request_prepared_file_mutation_permission orelse return error.TestExpectedPreparedFileMutationCallback; var review_turn = TestReviewTurn.init("Approve this prepared mutation.", call); const accepted = try callback( runtime_deps.ctx, arena, call, &prepared, review_turn.context(), .ask, &.{}, null, &.{}, );
try std.testing.expectEqual(@as(usize, 1), prompt.calls); try std.testing.expectEqual(ToolPermissionDecision.once, accepted.decision); const authorization = switch (accepted.execution_authority orelse return error.TestExpectedEqual) { .file_mutation => |authorization| authorization, else => return error.TestExpectedEqual, }; try std.testing.expectEqualStrings(target_path, authorization.input.path()); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "Approve? [y/N]") != null); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);}
test "fx ask auto mode uses automatic allow for external prepared file mutation" { const FakeClassifier = struct { calls: usize = 0, root_text: []const u8 = "",
fn classify( raw_ctx: *anyopaque, alloc: Allocator, request: permission_auto_classifier.ReviewRequest, ) anyerror!permission_auto_classifier.ParseOutcome { const self: *@This() = @ptrCast(@alignCast(raw_ctx)); self.calls += 1; self.root_text = request.review_turn.trusted_root_context; try std.testing.expect(request.targets.len >= 1); const file = switch (request.action) { .file_mutation => |value| value, else => return error.TestExpectedEqual, }; try std.testing.expectEqualStrings("write_file", file.tool_name); return .{ .valid = .{ .risk = .medium, .decision = .clear, .rationale = try alloc.dupe(u8, "test automatic review"), } }; } };
const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); try tmp.dir.createDirPath(io_mod.getIo(), "external"); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace); const external = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "external"); defer alloc.free(external);
var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), workspace); defer ctx.deinit(); var fake = FakeClassifier{}; ctx.auto_classifier = permission_auto_classifier.Classifier.withOverride( @ptrCast(&fake), FakeClassifier.classify, );
const target_path = try std.fs.path.join(arena, &.{ external, "desktop-test.txt" }); const arguments_json = try std.fmt.allocPrint( arena, "{{\"path\":\"{s}\",\"content\":\"hello\\n\"}}", .{target_path}, ); const call: ToolCall = .{ .id = "external-write", .name = "write_file", .arguments_json = arguments_json, }; var review_turn = TestReviewTurn.init("Write hello to desktop-test.txt.", call); const accepted = try requestToolPermissionOutcomeWithRequest(&ctx, arena, call, review_turn.context(), .auto, &.{}, null, null, &.{}, null);
try std.testing.expectEqual(@as(usize, 0), fake.calls); try std.testing.expectEqualStrings("", fake.root_text); try std.testing.expectEqual(ToolPermissionDecision.once, accepted.decision); const authorization = switch (accepted.execution_authority orelse return error.TestExpectedEqual) { .file_mutation => |authorization| authorization, else => return error.TestExpectedEqual, }; try std.testing.expectEqualStrings(target_path, authorization.input.path());}
test "fx ask preserves CLI headless blocker diagnostics" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), workspace, ); defer ctx.deinit(); var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator();
ctx.permission_rules = try testPermissionRuleSet(alloc, "bash", "touch configured.txt", .ask); const configured_rule_ask = ToolCall{ .id = "configured-rule-ask", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch configured.txt\"}", }; const configured_label = try tool_presentation.formatPlainAction(arena, .{ .tool_registry = ctx.toolRegistry(), .call = configured_rule_ask }); try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome( &ctx, arena, configured_rule_ask, .ask, &.{}, &.{}, )); const expected_configured_stderr = try std.fmt.allocPrint( arena, "fx ask: permission required by configured rule\n" ++ "fx ask: blocked action: {s}\n" ++ "fx ask: reason=noninteractive_permission_prompt_unavailable\n" ++ "fx ask: rerun in the interactive shell to approve this action, or add a narrow matching permission rule before retrying\n", .{configured_label}, ); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings(expected_configured_stderr, stderr_capture.bytes.items);
ctx.permission_rules.deinit(alloc); ctx.permission_rules = .{}; stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity();
const approval_required = ToolCall{ .id = "approval-required", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"touch approval.txt\"}", }; const approval_label = try tool_presentation.formatPlainAction(arena, .{ .tool_registry = ctx.toolRegistry(), .call = approval_required }); try std.testing.expectError(error.NonInteractivePermissionRequired, requestToolPermissionOutcome( &ctx, arena, approval_required, .ask, &.{}, &.{}, )); const expected_approval_stderr = try std.fmt.allocPrint( arena, "fx ask: permission required for tool execution in noninteractive mode\n" ++ "fx ask: blocked action: {s}\n" ++ "fx ask: reason=noninteractive_permission_prompt_unavailable\n" ++ "fx ask: rerun with --auto to review this exact action automatically, or use the interactive shell to approve it\n", .{approval_label}, ); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings(expected_approval_stderr, stderr_capture.bytes.items);
stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity();
const auto_approval = try requestToolPermissionOutcome( &ctx, arena, approval_required, .auto, &.{}, &.{}, ); try std.testing.expectEqual(ToolPermissionDecision.deny, auto_approval.decision); try std.testing.expectEqual(types.ToolPermissionDenialReason.review_unavailable, auto_approval.denial_reason.?); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);
stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity();
const default_safe_web_search = try requestToolPermissionOutcome( &ctx, arena, .{ .id = "web-search-default", .name = "web_search", .arguments_json = "{\"query\":\"current news\"}", }, .auto, &.{}, &.{}, ); try std.testing.expectEqual(ToolPermissionDecision.once, default_safe_web_search.decision); try std.testing.expect(default_safe_web_search.execution_authority != null); try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "runWithDeps projects exec-only terminal when saved setup has no capability" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps( alloc, &.{"hello"}, testConfig(), testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptChecksExecOnlyTerminal), );
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqualStrings("assistant text", stdout_capture.bytes.items);}
test "runWithDeps uses the supplied tool set for advertisement and runtime" { const alloc = std.testing.allocator; const tools = [_]tool_dispatch.Tool{builtin_tools.read_file}; const names = [_][]const u8{"read_file"}; const tool_set: tool_set_contract.ToolSet = .{ .registry = .{ .tools = tools[0..] }, .order = names[0..], .read_only_tool_names = names[0..], }; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptChecksInjectedToolSet, ); deps.tool_set = tool_set;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqualStrings("assistant text", stdout_capture.bytes.items);}
test "final ask json keeps shell tool call shape and adds command result" { const alloc = std.testing.allocator; const records = try alloc.alloc(ToolCallRecord, 1); records[0] = .{ .name = try alloc.dupe(u8, "shell"), .status = try alloc.dupe(u8, "success"), .command_result_json = try alloc.dupe(u8, "{\"kind\":\"command\",\"command\":\"printf ok\",\"cwd\":\"/tmp\",\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"stdout_bytes\":2,\"stderr_bytes\":0,\"truncated\":false}"), }; const result = PromptRunResult{ .exit_code = 0, .assistant_output = try alloc.dupe(u8, "ok"), .model = try alloc.dupe(u8, "test-model"), .session_id = try alloc.dupe(u8, "session-1"), .tool_calls = records, .step_count = 1, }; defer result.deinit(alloc);
const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit(); const tool_call = parsed.value.object.get("tool_calls").?.array.items[0].object; try std.testing.expectEqualStrings("shell", tool_call.get("name").?.string); try std.testing.expectEqualStrings("success", tool_call.get("status").?.string); const command_result = tool_call.get("command_result").?.object; try std.testing.expectEqualStrings("command", command_result.get("kind").?.string); try std.testing.expectEqual(@as(i64, 0), command_result.get("exit_code").?.integer); try std.testing.expectEqual(@as(i64, 2), command_result.get("stdout_bytes").?.integer);}
test "runWithDeps honors no-save by skipping ask session stores" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
test_initialize_session_store_calls = 0; var deps = testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptChecksExecOnlyTerminal); deps.initialize_session_stores = testCountSessionStores;
const exit_code = try runWithDeps( alloc, &.{ "--no-save", "hello" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual(@as(usize, 0), test_initialize_session_store_calls);}
test "runWithDeps retains full terminal projection with saved capability" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.initialize_session_stores = initializeSessionStoresDefault; deps.process_queued_prompt = testProcessQueuedPromptChecksFullTerminal;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqualStrings("assistant text", stdout_capture.bytes.items);}
test "exec-only terminal projection propagates view allocation failure" { var failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); try std.testing.expectError( error.OutOfMemory, buildAskGatewayToolProjection( failing.allocator(), test_mode_registry, builtin_tools.advertisement_set, "inspect", .{}, false, ), );}
const TestAskHome = struct { alloc: Allocator, map: std.process.Environ.Map,
var stable_empty: ?*std.process.Environ.Map = null;
fn stableEmptyEnv() !*const std.process.Environ.Map { if (stable_empty) |map| return map; const alloc = std.heap.page_allocator; const map = try alloc.create(std.process.Environ.Map); map.* = std.process.Environ.Map.init(alloc); stable_empty = map; return map; }
fn install(alloc: Allocator, home: []const u8) !*TestAskHome { _ = try stableEmptyEnv();
const self = try alloc.create(TestAskHome); errdefer alloc.destroy(self);
self.* = .{ .alloc = alloc, .map = std.process.Environ.Map.init(alloc), }; errdefer self.map.deinit();
try self.map.put("HOME", home); io_mod.setEnvironMap(&self.map); return self; }
fn deinit(self: *TestAskHome) void { if (stable_empty) |map| { io_mod.setEnvironMap(map); } self.map.deinit(); const alloc = self.alloc; alloc.destroy(self); }};
fn testAskDurableState( alloc: Allocator, workspace: []const u8, id: []const u8,) !session_codec.DurableSessionState { return .{ .id = try alloc.dupe(u8, id), .origin_workspace_root = try alloc.dupe(u8, workspace), .workspace_root = try alloc.dupe(u8, workspace), .created_at_ms = 1, .updated_at_ms = 1, .conversation_language = session_runtime.ConversationLanguage.literal("en"), .preferences = .{ .model = try alloc.dupe(u8, "model"), .effort = .auto, .fast_mode = false, }, .history = try alloc.alloc(HistoryTurn, 0), .total_input_tokens = 0, .total_output_tokens = 0, };}
test "fx ask renders one-off resume denial in text and JSON modes" { const alloc = std.testing.allocator; const cases = [_]struct { args: []const [:0]const u8, json: bool, }{ .{ .args = &.{ "--resume-id", "one-off-child", "continue" }, .json = false, }, .{ .args = &.{ "--json", "--resume-id", "one-off-child", "continue" }, .json = true, }, }; for (cases) |case| { var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.initialize_session_stores = testInitializeSessionStoresOneOffDenied;
const exit_code = try runWithDeps(alloc, case.args, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 1), exit_code); if (case.json) { var parsed = try std.json.parseFromSlice( std.json.Value, alloc, stdout_capture.bytes.items, .{}, ); defer parsed.deinit(); try std.testing.expectEqualStrings( "OneOffSessionNotResumable", parsed.value.object.get("error").?.string, ); try std.testing.expectEqualStrings("", stderr_capture.bytes.items); } else { try std.testing.expectEqualStrings("", stdout_capture.bytes.items); try std.testing.expectEqualStrings( "fx ask: subagent child sessions cannot be resumed directly; message the named agent from its parent session\n", stderr_capture.bytes.items, ); } }}
fn expectAskSessionStoresUnavailable(ctx: *const AskContext) !void { try std.testing.expect(ctx.store == null); try std.testing.expect(ctx.writable == null); try std.testing.expect(ctx.subagent_host == null);}
fn exerciseSavedAskSessionStoreAllocation( alloc: Allocator, enforce_borrow_invariant: bool,) !void { const setup_alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(setup_alloc, tmp.dir, "home"); defer setup_alloc.free(home); const workspace = try io_mod.dirRealpathAlloc( setup_alloc, tmp.dir, "workspace", ); defer setup_alloc.free(workspace);
const test_home = try TestAskHome.install(setup_alloc, home); defer test_home.deinit(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(setup_alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(setup_alloc); const deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ); var ctx = AskContext.init( alloc, testConfig(), deps, workspace, ); defer ctx.deinit(); ctx.session.setConversationLanguageFromUserMessage("persist this turn");
ctx.initializeSessionStores() catch { _ = enforce_borrow_invariant; return; }; _ = enforce_borrow_invariant;}
test "saved ask allocation failures keep managed borrows owned" { const backing = std.testing.allocator; var counting = std.testing.FailingAllocator.init(backing, .{}); try exerciseSavedAskSessionStoreAllocation(counting.allocator(), false);
for (0..counting.alloc_index) |fail_index| { var failing = std.testing.FailingAllocator.init( backing, .{ .fail_index = fail_index }, ); exerciseSavedAskSessionStoreAllocation( failing.allocator(), true, ) catch |err| { std.debug.print( "saved ask allocation fail_index={d} violated ownership: {s}\n", .{ fail_index, @errorName(err) }, ); return err; }; try std.testing.expect(failing.has_induced_failure); try std.testing.expectEqual( failing.allocated_bytes, failing.freed_bytes, ); }}
test "saved ask classifies unsafe store failure by request mode" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); var blocker = try tmp.dir.createFile(io_mod.getIo(), "home/.fx", .{}); blocker.close(io_mod.getIo());
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var fresh_ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), workspace, ); defer fresh_ctx.deinit();
try fresh_ctx.initializeSessionStores();
try std.testing.expectEqualStrings( "fx ask: warning: session persistence unavailable; error=SessionPathUnsafe; continuing without saving\n", stderr_capture.bytes.items, ); try expectAskSessionStoresUnavailable(&fresh_ctx);
stderr_capture.bytes.clearRetainingCapacity(); var resume_ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), workspace, ); defer resume_ctx.deinit(); resume_ctx.requested_resume = .last;
try std.testing.expectError( error.SessionPathUnsafe, resume_ctx.initializeSessionStores(), );
try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); try expectAskSessionStoresUnavailable(&resume_ctx);}
test "saved ask propagates store allocation failure" { const setup_alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(setup_alloc, tmp.dir, "home"); defer setup_alloc.free(home); const workspace = try io_mod.dirRealpathAlloc( setup_alloc, tmp.dir, "workspace", ); defer setup_alloc.free(workspace); const test_home = try TestAskHome.install(setup_alloc, home); defer test_home.deinit();
var failing = std.testing.FailingAllocator.init( setup_alloc, .{ .fail_index = 0 }, ); const alloc = failing.allocator(); var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(setup_alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(setup_alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), workspace, ); defer ctx.deinit();
try std.testing.expectError( error.OutOfMemory, ctx.initializeSessionStores(), );
try std.testing.expect(failing.has_induced_failure); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); try expectAskSessionStoresUnavailable(&ctx);}
test "saved ask initializes the direct subagent host" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace);
const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), workspace); defer ctx.deinit(); ctx.session.setConversationLanguageFromUserMessage("run a command");
try ctx.initializeSessionStores();
try std.testing.expect(ctx.subagent_host != null); const deps = agentRuntimeDeps(&ctx); try std.testing.expect(deps.prepare_parent_turn_context == null); try std.testing.expect(deps.acknowledge_parent_turn_context == null); try std.testing.expect(ctx.writable != null); try std.testing.expect(ctx.writable.?.state.usage != null);
var store = try session_store.Store.initFromHome(alloc, home, workspace); defer store.deinit(alloc); var sessions = try store.list(alloc); defer { for (sessions.items) |*summary| summary.deinit(alloc); sessions.deinit(alloc); } try std.testing.expectEqual(@as(usize, 1), sessions.items.len); try std.testing.expectEqualStrings(workspace, sessions.items[0].workspace_root.?);}
test "headless ask overwrites session usage from latest completion" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace);
const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), workspace); defer ctx.deinit();
try ctx.initializeSessionStores(); try std.testing.expect(ctx.writable != null);
const deps = agentRuntimeDeps(&ctx); const report_fn = deps.report_usage orelse return error.TestExpectedEqual; report_fn(deps.ctx, .{ .input_tokens = 100, .output_tokens = 20 }); report_fn(deps.ctx, .{ .input_tokens = 107, .output_tokens = 23 });
try std.testing.expectEqual(@as(u64, 107), ctx.writable.?.state.total_input_tokens); try std.testing.expectEqual(@as(u64, 23), ctx.writable.?.state.total_output_tokens);}
test "saved ask settles profile publication before persistence teardown" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), workspace, ); var ctx_live = true; defer if (ctx_live) ctx.deinit(); try ctx.initializeSessionStores(); _ = agentRuntimeDeps(&ctx);
const PublicationProbe = struct { allow_generation: bool = false, successful_generations: usize = 0,
fn publish( raw: *anyopaque, event: usage_report.ProfileEvent, ) !void { const self: *@This() = @ptrCast(@alignCast(raw)); switch (event) { .generation => { if (!self.allow_generation) { return error.InjectedPublicationFailure; } self.successful_generations += 1; }, .pending, .incident => {}, } } }; var publication: PublicationProbe = .{}; ctx.session.usage.configurePublicationSink(.{ .context = &publication, .allocator = alloc, .publish = PublicationProbe.publish, });
const generation_id = "gen_01ARZ3NDEKTSV4RRFFQ69G5FAV"; const sequence = try ctx.session.usage.reserveInvocation(); try ctx.session.usage.finishObservedInvocation( alloc, sequence, 1, .observed_generation, generation_id, "https://ai-gateway.vercel.sh", null, ); try ctx.session.usage.applyGeneration(alloc, .{ .id = generation_id, .created_at_ms = 1, .model = "provider/model", .total_cost = 0.25, .input_tokens = 10, .output_tokens = 2, .cache_read_tokens = 1, .cache_write_tokens = 0, .reasoning_tokens = 1, .billable_web_search_calls = 0, }); var pending = try ctx.session.usage.snapshot(alloc); defer pending.deinit(alloc); try std.testing.expectEqual(@as(usize, 1), pending.pending.len); try std.testing.expectEqual(@as(usize, 1), pending.publication_backlog.len);
publication.allow_generation = true; ctx.deinit(); ctx_live = false; try std.testing.expectEqual(@as(usize, 1), publication.successful_generations);
var store = try session_store.Store.initFromHome(alloc, home, workspace); defer store.deinit(alloc); var resumed = try store.resumeTargetForWrite( alloc, .last, workspace, .{}, ); defer resumed.deinit(alloc); const usage = resumed.state.usage orelse return error.TestExpectedEqual; try std.testing.expectEqual(session_usage.Availability.complete, usage.billing); try std.testing.expectEqual(@as(usize, 0), usage.pending.len); try std.testing.expectEqual(@as(usize, 0), usage.publication_backlog.len); try std.testing.expectEqual(@as(u64, 10), usage.input_tokens); try std.testing.expectEqual(@as(u64, 2), usage.output_tokens);}
test "saved ask ignores existing legacy task files" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); try tmp.dir.createDirPath(io_mod.getIo(), "workspace");
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const workspace = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(workspace);
const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var store = try session_store.Store.initFromHome(alloc, home, workspace); defer store.deinit(alloc); const session_id = "saved-ask-task-route-failure"; var state = try testAskDurableState(alloc, workspace, session_id); defer state.deinit(alloc); var writable = try store.startWritableSession(alloc, state); writable.deinit(alloc);
const session_dir = try session_store.sessionDirPath( alloc, store.sessions_dir, session_id, ); defer alloc.free(session_dir); const tasks_path = try std.fs.path.join(alloc, &.{ session_dir, "tasks" }); defer alloc.free(tasks_path); var tasks_file = try std.Io.Dir.createFileAbsolute( io_mod.getIo(), tasks_path, .{ .truncate = true, .permissions = std.Io.File.Permissions.fromMode(0o600), }, ); tasks_file.close(io_mod.getIo());
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), workspace, ); defer ctx.deinit(); ctx.requested_resume = .{ .id = session_id };
try ctx.initializeSessionStores(); try std.testing.expect(ctx.writable != null); try std.testing.expect(ctx.subagent_host != null);}
test "parse options trims explicit stdin fallback" { var options = try parseOptionsWithStdin(std.testing.allocator, &.{}, .{ .bytes = " \n prompt from stdin \t" }); defer options.deinit(std.testing.allocator);
try std.testing.expectEqualStrings("prompt from stdin", options.prompt);}
test "parse options rejects model-unsafe prompt text from arguments and stdin" { try std.testing.expectError( error.InvalidPromptText, parseOptionsWithStdin(std.testing.allocator, &.{"bad\x00prompt"}, .tty), ); try std.testing.expectError( error.InvalidPromptText, parseOptionsWithStdin(std.testing.allocator, &.{}, .{ .bytes = "bad\xffprompt" }), );}
test "parse options preserves exact resume-id operands" { var options = try parseOptionsWithStdin( std.testing.allocator, &.{ "--resume-id", "last", "continue" }, .tty, ); defer options.deinit(std.testing.allocator);
switch (options.resume_target.?) { .last => return error.TestExpectedExactResumeId, .id => |id| try std.testing.expectEqualStrings("last", id), } try std.testing.expectEqualStrings("continue", options.prompt);}
test "parse options requires an explicit saved session for recovery continuation" { var options = try parseOptionsWithStdin( std.testing.allocator, &.{ "--resume-id", "session.v3", "--continue-recovery" }, .tty, ); defer options.deinit(std.testing.allocator);
try std.testing.expect(options.continue_recovery); try std.testing.expectEqual(@as(usize, 0), options.prompt.len); try std.testing.expectError( error.InvalidAskArgs, parseOptionsWithStdin( std.testing.allocator, &.{"--continue-recovery"}, .tty, ), ); try std.testing.expectError( error.InvalidAskArgs, parseOptionsWithStdin( std.testing.allocator, &.{ "--resume", "last", "--continue-recovery", "new prompt" }, .tty, ), );}
test "parse options rejects repeated resume targets and no-save resume" { try std.testing.expectError( error.InvalidAskArgs, parseOptionsWithStdin( std.testing.allocator, &.{ "--resume", "last", "--resume-id", "session.v3", "continue" }, .tty, ), ); try std.testing.expectError( error.NoSaveResumeConflict, parseOptionsWithStdin( std.testing.allocator, &.{ "--no-save", "--resume", "last", "continue" }, .tty, ), ); try std.testing.expectError( error.NoSaveResumeConflict, parseOptionsWithStdin( std.testing.allocator, &.{ "--resume-id", "session.v3", "--no-save", "continue" }, .tty, ), );}
test "ask usage survives normal and typed error result capture" { const alloc = std.testing.allocator; const Outcome = enum { success, read_failed, permission_required }; inline for (std.meta.tags(Outcome)) |outcome| { const Process = struct { fn run(agent: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, _: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { agent.startTurn(); agent.observeUsage(.{ .input_tokens = 10, .output_tokens = 20 }); agent.observeUsage(.{ .input_tokens = 7, .output_tokens = 3 }); try testPushAssistantText(deps, "reported usage"); if (std.mem.eql(u8, job.prompt, "read_failed")) return error.ReadFailed; if (std.mem.eql(u8, job.prompt, "permission_required")) return error.NonInteractivePermissionRequired; } }; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, Process.run); deps.stdout_is_tty = TestTty.no; const exit_code = try runWithDeps(alloc, &.{ "--json", "--no-save", @tagName(outcome) }, testConfig(), deps); try std.testing.expectEqual(@as(u8, if (outcome == .success) 0 else 1), exit_code); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); const usage = parsed.value.object.get("usage").?.object; try std.testing.expectEqual(@as(i64, 17), usage.get("input_tokens").?.integer); try std.testing.expectEqual(@as(i64, 23), usage.get("output_tokens").?.integer); try std.testing.expectEqualStrings("reported usage", parsed.value.object.get("output").?.string); try std.testing.expectEqualStrings("", stderr_capture.bytes.items); switch (outcome) { .success => try std.testing.expect(parsed.value.object.get("error") == null), .read_failed => try std.testing.expectEqualStrings("ReadFailed", parsed.value.object.get("error").?.string), .permission_required => try std.testing.expectEqualStrings("NonInteractivePermissionRequired", parsed.value.object.get("error").?.string), } }}
test "ask usage JSON distinguishes unavailable totals from reported zero" { const alloc = std.testing.allocator; const json = try renderFinalJsonResult(alloc, .{ .exit_code = 0, .assistant_output = &.{}, .usage = .{ .input_tokens = 0 }, }); defer alloc.free(json); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, json, .{}); defer parsed.deinit(); const usage = parsed.value.object.get("usage").?.object; try std.testing.expectEqual(@as(i64, 0), usage.get("input_tokens").?.integer); try std.testing.expect(usage.get("output_tokens").? == .null);}
test "render final JSON preserves shape escaping order and newline" { const alloc = std.testing.allocator; const tool_calls = try alloc.alloc(ToolCallRecord, 1); tool_calls[0] = .{ .name = try alloc.dupe(u8, "read_file"), .status = try alloc.dupe(u8, "success"), }; const result = PromptRunResult{ .exit_code = 0, .assistant_output = try alloc.dupe(u8, "hello \"zig\"\n"), .model = try alloc.dupe(u8, "model-x"), .session_id = try alloc.dupe(u8, "123"), .tool_calls = tool_calls, .step_count = 2, }; defer result.deinit(alloc);
const json = try renderFinalJsonResult(alloc, result); defer alloc.free(json);
try std.testing.expectEqualStrings( "{\"output\":\"hello \\\"zig\\\"\\n\",\"final_output\":\"\",\"exit_code\":0,\"model\":\"model-x\",\"session_id\":\"123\",\"steps\":2,\"tool_calls\":[{\"name\":\"read_file\",\"status\":\"success\"}],\"usage\":{\"input_tokens\":null,\"output_tokens\":null}}\n", json, );}
test "render final JSON emits empty tool call array" { const alloc = std.testing.allocator; const result = PromptRunResult{ .exit_code = 1, .assistant_output = try alloc.dupe(u8, ""), }; defer result.deinit(alloc);
const json = try renderFinalJsonResult(alloc, result); defer alloc.free(json);
try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null}}\n", json, );}
test "render final JSON reports the successful recovery attempt" { const alloc = std.testing.allocator; const result = PromptRunResult{ .exit_code = 0, .assistant_output = try alloc.dupe(u8, "recovered"), .recovery = .{ .kind = .auto_recovered, .succeeded_attempt = 3, .attempt_limit = 10, }, }; defer result.deinit(alloc);
const json = try renderFinalJsonResult(alloc, result); defer alloc.free(json); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, json, .{}); defer parsed.deinit(); const recovery = parsed.value.object.get("recovery").?.object; try std.testing.expectEqual(@as(i64, 3), recovery.get("attempt").?.integer); try std.testing.expectEqualStrings("recovered", recovery.get("state").?.string); try std.testing.expectEqualStrings( "✓ recovered · succeeded on attempt 3", recovery.get("message").?.string, ); try std.testing.expect(std.mem.find(u8, recovery.get("message").?.string, "provider_error") == null);}
test "render final JSON includes the latest terminal recovery diagnostic" { const alloc = std.testing.allocator; const result = PromptRunResult{ .exit_code = 1, .assistant_output = try alloc.dupe(u8, ""), .recovery = .{ .kind = .terminal_provider_error, .failed_attempt = 2, .attempt_limit = 2, .cause = .provider_unavailable, .diagnostic = types.ModelFailureDiagnostic.init( "HTTP 503 · no_available_providers: No providers are currently available", ), }, }; defer result.deinit(alloc);
const json = try renderFinalJsonResult(alloc, result); defer alloc.free(json); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, json, .{}); defer parsed.deinit(); const recovery = parsed.value.object.get("recovery").?.object; try std.testing.expectEqualStrings("failed", recovery.get("state").?.string); try std.testing.expectEqualStrings( "⚠ Provider unavailable · HTTP 503 · no_available_providers: No providers are currently available · stopped after 2 attempts", recovery.get("message").?.string, );}
test "cli json records built in web_search completion" { const alloc = std.testing.allocator; const records = try alloc.alloc(ToolCallRecord, 1); records[0] = .{ .name = try alloc.dupe(u8, "web_search"), .status = try alloc.dupe(u8, "success"), .web_search_completion = .{ .searches = 3, .duration_ms = 42 }, }; const result = PromptRunResult{ .exit_code = 0, .assistant_output = try alloc.dupe(u8, ""), .tool_calls = records, }; defer result.deinit(alloc);
const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit(); const web_search = parsed.value.object.get("tool_calls").?.array.items[0].object.get("web_search").?.object; try std.testing.expectEqual(@as(i64, 3), web_search.get("searches").?.integer); try std.testing.expectEqual(@as(i64, 42), web_search.get("duration_ms").?.integer);}
test "fx ask JSON captures HTTP 413 prompt-too-long blocker" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps( alloc, &.{ "--json", "--no-save", "hello" }, testConfig(), testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptHttp413), );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "fx ask: HTTP 413: provider payload rejected") != null);
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); const output = parsed.value.object.get("output").?.string; try std.testing.expect(std.mem.find(u8, output, "HTTP 413") != null); try std.testing.expect(std.mem.find(u8, output, "prompt_too_long=true") != null); try std.testing.expect(std.mem.find(u8, output, "Provider rejected the prompt as too large") != null); try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("exit_code").?.integer);}
test "fx ask formats restricted-provider HTTP errors without raw JSON" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps( alloc, &.{ "--json", "--no-save", "hello" }, testConfig(), testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptRestrictedProvider), );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "fx ask: API access denied · HTTP 403 · Provider: wafer") != null); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "{\"error\"") == null);
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); const output = parsed.value.object.get("output").?.string; try std.testing.expect(std.mem.find(u8, output, "API access denied · HTTP 403 · Provider: wafer") != null); try std.testing.expect(std.mem.find(u8, output, "{\"error\"") == null); try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("exit_code").?.integer);}
test "fx ask text and JSON share the selected auth failure facts" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps( alloc, &.{ "--json", "--no-save", "hello" }, testConfig(), testPromptRunDepsWithProcess(&stdout_capture, &stderr_capture, testProcessQueuedPromptUnauthorized), );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqualStrings( "fx ask: AI_GATEWAY_API_KEY authentication failed · HTTP 401\n", stderr_capture.bytes.items, ); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("exit_code").?.integer); try std.testing.expectEqualStrings( "AI_GATEWAY_API_KEY authentication failed · HTTP 401\n", parsed.value.object.get("output").?.string, ); const auth_failure = parsed.value.object.get("auth_failure").?.object; try std.testing.expectEqualStrings("AI_GATEWAY_API_KEY", auth_failure.get("source").?.string); try std.testing.expectEqualStrings("http_unauthorized", auth_failure.get("reason").?.string); try std.testing.expectEqual(@as(i64, 401), auth_failure.get("http_status").?.integer); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "secret-key") == null); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "secret-key") == null); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "\x1b") == null);}
test "saved API key 401 discards the fresh pristine session" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.process_queued_prompt = testProcessQueuedPromptUnauthorized; deps.initialize_session_stores = initializeSessionStoresDefault;
const exit_code = try runWithDeps( alloc, &.{ "--json", "hello" }, testConfig(), deps, ); try std.testing.expectEqual(@as(u8, 1), exit_code); var parsed = try std.json.parseFromSlice( std.json.Value, alloc, stdout_capture.bytes.items, .{}, ); defer parsed.deinit(); try std.testing.expectEqualStrings( "", parsed.value.object.get("session_id").?.string, ); try std.testing.expectEqual(@as(i64, 0), parsed.value.object.get("steps").?.integer); try std.testing.expectEqual(@as(usize, 0), parsed.value.object.get("tool_calls").?.array.items.len); try std.testing.expect(parsed.value.object.get("auth_failure") != null);
var store = try session_store.Store.initFromHome(alloc, home, "/tmp/fx-test"); defer store.deinit(alloc); var sessions = try store.list(alloc); defer { for (sessions.items) |*summary| summary.deinit(alloc); sessions.deinit(alloc); } try std.testing.expectEqual(@as(usize, 0), sessions.items.len); try std.testing.expectError( error.NoSavedSessions, store.resumeTargetForWrite(alloc, .last, "/tmp/fx-test", .{}), );}
test "saved failures retain ineligible session lifecycles" { const Case = struct { kind: enum { resumed, committed_history, tool_started, generic_failure }, process: ProcessQueuedPromptFn, expected_history_len: usize, expected_steps: usize, }; const cases = [_]Case{ .{ .kind = .resumed, .process = testProcessQueuedPromptUnauthorized, .expected_history_len = 0, .expected_steps = 0, }, .{ .kind = .committed_history, .process = testProcessQueuedPromptUnauthorizedThenHistory, .expected_history_len = 1, .expected_steps = 0, }, .{ .kind = .tool_started, .process = testProcessQueuedPromptToolThenUnauthorized, .expected_history_len = 0, .expected_steps = 1, }, .{ .kind = .generic_failure, .process = testProcessQueuedPromptHttp413, .expected_history_len = 0, .expected_steps = 0, }, };
const alloc = std.testing.allocator; for (cases) |case| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
if (case.kind == .resumed) { var seed_store = try session_store.Store.initFromHome( alloc, home, "/tmp/fx-test", ); defer seed_store.deinit(alloc); var seed_state = try testAskDurableState( alloc, "/tmp/fx-test", "cli-protected-resume", ); defer seed_state.deinit(alloc); var seed = try seed_store.startWritableSession(alloc, seed_state); seed.deinit(alloc); }
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var probe = DiscardProbe{ .disposition = .indeterminate }; var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.initialize_session_stores = initializeSessionStoresDefault; deps.process_queued_prompt = case.process; deps.discard_pristine_session_ctx = &probe; deps.discard_pristine_session = DiscardProbe.run; const args: []const [:0]const u8 = switch (case.kind) { .resumed => &.{ "--json", "--resume-id", "cli-protected-resume", "hello" }, .committed_history, .tool_started, .generic_failure => &.{ "--json", "hello" }, };
const exit_code = try runWithDeps(alloc, args, testConfig(), deps); try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 0), probe.calls); var parsed = try std.json.parseFromSlice( std.json.Value, alloc, stdout_capture.bytes.items, .{}, ); defer parsed.deinit(); const session_id = parsed.value.object.get("session_id").?.string; try std.testing.expect(session_id.len > 0); try std.testing.expectEqual( @as(i64, @intCast(case.expected_steps)), parsed.value.object.get("steps").?.integer, );
var store = try session_store.Store.initFromHome( alloc, home, "/tmp/fx-test", ); defer store.deinit(alloc); var loaded = try store.loadReadOnly(alloc, session_id); defer loaded.deinit(alloc); try std.testing.expectEqual(case.expected_history_len, loaded.history.len); }}
test "saved auth fact followed by a prompt error retains the session" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var probe = DiscardProbe{ .disposition = .discarded }; var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.initialize_session_stores = initializeSessionStoresDefault; deps.process_queued_prompt = testProcessQueuedPromptUnauthorizedThenError; deps.discard_pristine_session_ctx = &probe; deps.discard_pristine_session = DiscardProbe.run;
try std.testing.expectError( error.InjectedPromptFailure, runWithDeps(alloc, &.{"hello"}, testConfig(), deps), ); try std.testing.expectEqual(@as(usize, 0), probe.calls); var store = try session_store.Store.initFromHome(alloc, home, "/tmp/fx-test"); defer store.deinit(alloc); var sessions = try store.list(alloc); defer { for (sessions.items) |*summary| summary.deinit(alloc); sessions.deinit(alloc); } try std.testing.expectEqual(@as(usize, 1), sessions.items.len);}
test "indeterminate saved auth cleanup keeps the primary result and session id" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var probe = DiscardProbe{ .disposition = .indeterminate }; var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeySavedStartup, ); deps.initialize_session_stores = initializeSessionStoresDefault; deps.process_queued_prompt = testProcessQueuedPromptUnauthorized; deps.discard_pristine_session_ctx = &probe; deps.discard_pristine_session = DiscardProbe.run;
const exit_code = try runWithDeps( alloc, &.{ "--json", "hello" }, testConfig(), deps, ); try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 1), probe.calls); try std.testing.expect(probe.borrowers_detached); try std.testing.expectEqualStrings( "fx ask: AI_GATEWAY_API_KEY authentication failed · HTTP 401\n", stderr_capture.bytes.items, ); var parsed = try std.json.parseFromSlice( std.json.Value, alloc, stdout_capture.bytes.items, .{}, ); defer parsed.deinit(); try std.testing.expect(parsed.value.object.get("error") == null); try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("exit_code").?.integer); try std.testing.expectEqualStrings( "AI_GATEWAY_API_KEY authentication failed · HTTP 401\n", parsed.value.object.get("output").?.string, ); const session_id = parsed.value.object.get("session_id").?.string; try std.testing.expect(session_id.len > 0);
var store = try session_store.Store.initFromHome(alloc, home, "/tmp/fx-test"); defer store.deinit(alloc); var loaded = try store.loadReadOnly(alloc, session_id); defer loaded.deinit(alloc); try std.testing.expectEqual(@as(usize, 0), loaded.history.len);}
test "fx ask JSON records permission-denied tool calls as error status" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(root);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init(alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), root); defer ctx.deinit(); ctx.output_mode = .json;
var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); const arena = arena_state.allocator();
try recordToolCallRejected(@ptrCast(&ctx), arena, .{ .id = "cmd", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"printf secret\"}", }, "{\"error\":{\"type\":\"tool_permission_denied\"}}", "{\"kind\":\"command\"}");
try std.testing.expectEqual(@as(usize, 1), ctx.tool_call_records.items.len); try std.testing.expectEqualStrings("shell", ctx.tool_call_records.items[0].name); try std.testing.expectEqualStrings("error", ctx.tool_call_records.items[0].status); try std.testing.expectEqualStrings( "{\"kind\":\"command\"}", ctx.tool_call_records.items[0].command_result_json.?, );}
test "fx ask JSON preserves shell action and runtime error identity" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "workspace"); defer alloc.free(root);
var ctx = AskContext.init(alloc, testConfig(), .{ .context_registry = test_no_context_registry, .tool_set = builtin_tools.advertisement_set, .load_mcp_runtime = testNoMcpRuntime, }, root); defer ctx.deinit(); ctx.output_mode = .json;
captureToolExecutionError(&ctx, .{ .call_allocator = alloc, .result_allocator = alloc, .call = .{ .id = "shell-runtime-error", .name = "shell", .arguments_json = "{\"action\":\"interact\",\"session_id\":\"shell-missing\"}", }, .authority = .ordinary, .session_grants = &.{}, .advertised_dynamic_tool_names = &.{}, .max_tool_result_bytes = 4096, }, error.ExecutionNotFound);
var arena_state = std.heap.ArenaAllocator.init(alloc); defer arena_state.deinit(); try recordToolCallRejected(@ptrCast(&ctx), arena_state.allocator(), .{ .id = "shell-invalid-action", .name = "shell", .arguments_json = "{\"action\":\"legacy\"}", }, "Invalid shell action", null);
const records = try takeToolCallRecords(&ctx, alloc); const result = PromptRunResult{ .exit_code = 1, .assistant_output = try alloc.dupe(u8, ""), .tool_calls = records, }; defer result.deinit(alloc); const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit(); const record = parsed.value.object.get("tool_calls").?.array.items[0].object; const action = record.get("action") orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("interact", action.string); const failure = (record.get("error") orelse return error.TestExpectedEqual).object; try std.testing.expectEqualStrings("runtime_failed", failure.get("category").?.string); try std.testing.expectEqualStrings("ExecutionNotFound", failure.get("code").?.string);
const rejected = parsed.value.object.get("tool_calls").?.array.items[1].object; try std.testing.expect(rejected.get("action") == null); const rejected_failure = (rejected.get("error") orelse return error.TestExpectedEqual).object; try std.testing.expectEqualStrings( "rejected", rejected_failure.get("category").?.string, ); try std.testing.expectEqualStrings( "rejected", rejected_failure.get("code").?.string, );}
test "shell diagnostic action projection accepts only current actions" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const cases = [_]struct { arguments_json: []const u8, expected: ShellAction, }{ .{ .arguments_json = "{\"action\":\"run\"}", .expected = .run }, .{ .arguments_json = "{\"action\":\"interact\"}", .expected = .interact }, .{ .arguments_json = "{\"request\":{\"action\":\"stop\"}}", .expected = .stop }, }; for (cases) |case| { try std.testing.expectEqual( case.expected, shell_action_for_call(arena, .{ .id = "shell-action", .name = "shell", .arguments_json = case.arguments_json, }).?, ); } try std.testing.expect(shell_action_for_call(arena, .{ .id = "shell-action-invalid", .name = "shell", .arguments_json = "{\"action\":\"legacy\"}", }) == null);}
test "shell diagnostic codes remain bounded semantic identifiers" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator();
try std.testing.expectEqualStrings( "termination_indeterminate", shell_failure_code(arena, .command_failed, .{ .status = .failure, .model_output = "failure", .command_result_json = "{\"termination_indeterminate\":true}", }), ); try std.testing.expectEqualStrings( "nonzero_exit", shell_failure_code(arena, .command_failed, .{ .status = .failure, .model_output = "failure", .command_result_json = "{\"exit_code\":7}", }), ); try std.testing.expectEqualStrings( "command_failed", shell_failure_code(arena, .command_failed, .{ .status = .failure, .model_output = "failure", .command_result_json = "{\"exit_code\":0}", }), ); try std.testing.expectEqualStrings( "ExecutionNotFound", shell_failure_code(arena, .tool_failed, .{ .status = .failure, .model_output = "{\"error\":{\"tool\":\"shell\",\"code\":\"ExecutionNotFound\"}}", }), ); try std.testing.expectEqualStrings( "tool_failed", shell_failure_code(arena, .tool_failed, .{ .status = .failure, .model_output = "{\"error\":{\"tool\":\"shell\",\"code\":\"secret value\"}}", }), );}
test "fx ask JSON permission-denied capture is best effort under allocation failure" { var failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); var ctx = AskContext.init( failing.allocator(), testConfig(), .{ .context_registry = test_no_context_registry, .tool_set = builtin_tools.advertisement_set, .load_mcp_runtime = testNoMcpRuntime, }, "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .json;
try recordToolCallRejected(@ptrCast(&ctx), std.testing.allocator, .{ .id = "cmd", .name = "shell", .arguments_json = "{\"action\":\"run\",\"command\":\"printf secret\"}", }, "{\"error\":{\"type\":\"tool_permission_denied\"}}", null);
try std.testing.expectEqual(@as(usize, 0), ctx.tool_call_records.items.len);}
test "fx ask JSON captures parallel tool results without corrupting records" { const alloc = std.heap.c_allocator; const thread_count = 16; const calls_per_thread = 256; const expected_count = thread_count * calls_per_thread;
var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "workspace"); const root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "workspace"); defer std.testing.allocator.free(root);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(std.testing.allocator); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(std.testing.allocator); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyStartup, ), root, ); defer ctx.deinit(); ctx.output_mode = .json; try ctx.tool_call_records.ensureTotalCapacity(alloc, expected_count);
const CaptureWorker = struct { ctx: *AskContext, ready: *std.atomic.Value(usize), start: *std.atomic.Value(bool), failed: *std.atomic.Value(bool),
fn run(self: @This()) void { _ = self.ready.fetchAdd(1, .seq_cst); while (!self.start.load(.seq_cst)) { std.atomic.spinLoopHint(); }
for (0..calls_per_thread) |_| { var arena_state = std.heap.ArenaAllocator.init(alloc); const arena = arena_state.allocator(); _ = executeToolCallAuthorized(@ptrCast(self.ctx), .{ .call_allocator = arena, .result_allocator = arena, .call = .{ .id = "parallel-capture", .name = "glob_files", .arguments_json = "{\"pattern\":\"*\"}", }, .authority = .ordinary, .session_grants = &.{}, .advertised_dynamic_tool_names = &.{}, .max_tool_result_bytes = 4096, }) catch { self.failed.store(true, .seq_cst); arena_state.deinit(); return; }; arena_state.deinit(); } } };
var ready = std.atomic.Value(usize).init(0); var start = std.atomic.Value(bool).init(false); var failed = std.atomic.Value(bool).init(false); var threads: [thread_count]std.Thread = undefined; for (&threads) |*thread| { thread.* = try std.Thread.spawn(.{}, CaptureWorker.run, .{CaptureWorker{ .ctx = &ctx, .ready = &ready, .start = &start, .failed = &failed, }}); } while (ready.load(.seq_cst) != thread_count) { std.atomic.spinLoopHint(); } start.store(true, .seq_cst); for (threads) |thread| thread.join();
try std.testing.expect(!failed.load(.seq_cst)); try std.testing.expectEqual(expected_count, ctx.tool_call_records.items.len); for (ctx.tool_call_records.items) |record| { try std.testing.expectEqualStrings("glob_files", record.name); try std.testing.expectEqualStrings("success", record.status); }}
fn checkAskJsonCaptureAllocationFailures(alloc: Allocator) !void { var ctx = AskContext.init(alloc, testConfig(), .{ .context_registry = test_no_context_registry, .tool_set = builtin_tools.advertisement_set, .load_mcp_runtime = testNoMcpRuntime, }, "/tmp/workspace"); defer ctx.deinit(); ctx.output_mode = .json; try ctx.assistant_output.appendSlice(alloc, "assistant");
try appendToolCallRecord( &ctx, .{ .id = "capture", .name = "ask_user_question", .arguments_json = "{\"questions\":[{\"question\":\"Continue?\"}]}", }, "success", .{ .model_output = "contents", .command_result_json = "{\"ok\":true}", }, null, null, );
const result = try takePromptRunResult(&ctx, alloc); defer result.deinit(alloc); try std.testing.expectEqualStrings("assistant", result.assistant_output); try std.testing.expectEqual(@as(usize, 1), result.tool_calls.len); try std.testing.expectEqualStrings( "ask_user_question", result.tool_calls[0].name, ); try std.testing.expectEqualStrings("success", result.tool_calls[0].status); try std.testing.expectEqualStrings( "{\"ok\":true}", result.tool_calls[0].command_result_json.?, ); try std.testing.expectEqualStrings( "Continue?", result.tool_calls[0].ask_question_text.?, );}
test "fx ask JSON capture cleans up every allocation failure" { try std.testing.checkAllAllocationFailures( std.testing.allocator, checkAskJsonCaptureAllocationFailures, .{}, );}
test "fx ask JSON records ask_user_question text for matching assertions" { const alloc = std.testing.allocator; const records = try alloc.alloc(ToolCallRecord, 1); records[0] = .{ .name = try alloc.dupe(u8, "ask_user_question"), .status = try alloc.dupe(u8, "success"), .ask_question_text = try alloc.dupe(u8, "What is your GitHub handle?"), }; const result = PromptRunResult{ .exit_code = 0, .assistant_output = try alloc.dupe(u8, ""), .tool_calls = records, }; defer result.deinit(alloc);
const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit(); const tool_call = parsed.value.object.get("tool_calls").?.array.items[0].object; try std.testing.expectEqualStrings("ask_user_question", tool_call.get("name").?.string); try std.testing.expectEqualStrings("What is your GitHub handle?", tool_call.get("question").?.string);}
test "fx ask JSON clips ask_user_question text at a UTF-8 boundary" { const alloc = std.testing.allocator; var question_bytes: [257]u8 = undefined; @memset(question_bytes[0..255], 'a'); question_bytes[255] = 0xc3; question_bytes[256] = 0xa9; const arguments_json = try std.fmt.allocPrint( alloc, "{{\"questions\":[{{\"question\":\"{s}\"}}]}}", .{question_bytes[0..]}, ); defer alloc.free(arguments_json);
var ctx = AskContext.init(alloc, testConfig(), .{ .context_registry = test_no_context_registry, .tool_set = builtin_tools.advertisement_set, .load_mcp_runtime = testNoMcpRuntime, }, "/tmp/workspace"); defer ctx.deinit(); ctx.output_mode = .json; try appendToolCallRecord( &ctx, .{ .id = "question", .name = "ask_user_question", .arguments_json = arguments_json, }, "success", null, null, null, );
const result = try takePromptRunResult(&ctx, alloc); defer result.deinit(alloc); const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit();
const question = parsed.value.object.get("tool_calls").?.array.items[0].object.get("question").?; try std.testing.expect(question == .string); if (question != .string) return error.TestUnexpectedResult; try std.testing.expect(std.unicode.utf8ValidateSlice(question.string)); try std.testing.expectEqual(@as(usize, 255), question.string.len);}
test "json run with missing API key prints diagnostic then final object" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const exit_code = try runWithDeps(alloc, &.{ "--json", "hello" }, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testMissingKeyStartup));
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqualStrings("fx ask: " ++ credentials.missing_credential_message ++ "\n", stderr_capture.bytes.items); try std.testing.expectEqualStrings( "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"MissingCredentials\"}\n", stdout_capture.bytes.items, );}
test "resumed ask preserves user and image identity after a retained mid-turn checkpoint" { const Process = struct { fn run(_: *agent_runtime.Agent, deps: *const agent_runtime.AgentRuntimeDeps, _: ?agent_runtime.SemanticPresentationSink, _: agent_runtime.LifecycleContext, _: agent_runtime.Config, job: worker_runtime.QueuedPrompt) !void { if (job.recovery_checkpoint) |checkpoint| { try std.testing.expectEqual(@as(u64, 7), checkpoint.turn_id); try std.testing.expectEqualStrings("original request", job.prompt); try std.testing.expectEqual(@as(usize, 7), job.images[0].id); try std.testing.expectEqual(@as(usize, 1), job.authorized_image_catalog.len); try std.testing.expectEqualStrings(job.images[0].snapshot_sha256.?, job.authorized_image_catalog[0].snapshot_sha256.?); } try deps.propagate_history_turn(deps.ctx, .{ .assistant = .{ .user = .{ .text = job.prompt, .images = job.images }, .assistant = @constCast("new answer"), } }); try testPushAssistantText(deps, "new answer"); } }; const alloc = std.testing.allocator; const cases = [_]struct { prompt: [:0]const u8, continue_recovery: bool = false }{ .{ .prompt = "different request" }, .{ .prompt = "original request" }, .{ .prompt = "original request", .continue_recovery = true }, }; for (cases) |case| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit(); const session_id = "retained-open-session"; var store = try session_store.Store.initFromHome(alloc, home, "/tmp/fx-test"); defer store.deinit(alloc); var state = try testAskDurableState(alloc, "/tmp/fx-test", session_id); defer state.deinit(alloc); var images = [_]ImageAttachment{.{ .id = 7, .path = @constCast("/missing/original.png"), .media_type = @constCast("image/png"), .snapshot_path = @constCast("images/image-7-aaaaaaaaaaaaaaaa.bin"), .snapshot_sha256 = @constCast("a" ** 64), }}; const old_user = types.UserTurn{ .text = @constCast("original request"), .images = if (case.continue_recovery) &images else &.{}, }; { var writable = try store.startWritableSession(alloc, state); defer writable.deinit(alloc); _ = try writable.commitContextCompaction(alloc, .{ .summary = @constCast("<context_handoff>Earlier work completed.</context_handoff>"), .removed_turn_count = 0, .compaction_count = 1, }, .{ .user = old_user, .assistant = @constCast("") }, null, 10); _ = try writable.appendEvent(alloc, .{ .recovery_checkpoint_set = .{ .checkpoint = .{ .turn_id = 7, .user = old_user, .assistant_source = @constCast("old partial answer"), .cause = .network_interrupted, .action = .paused, .authority = .{ .provider = .gateway, .model = @constCast("model") }, .requested_fast_mode = false, .fast_mode = false, .max_provider_attempts = 3, .consumed_provider_attempts = 1, } } }, 20); }
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeySavedStartup); deps.initialize_session_stores = initializeSessionStoresDefault; deps.process_queued_prompt = Process.run; const args: []const [:0]const u8 = if (case.continue_recovery) &.{ "--json", "--resume-id", session_id, "--continue-recovery" } else &.{ "--json", "--resume-id", session_id, case.prompt }; try std.testing.expectEqual(@as(u8, 0), try runWithDeps(alloc, args, testConfig(), deps));
var restored = try store.loadReadOnly(alloc, session_id); defer restored.deinit(alloc); try std.testing.expectEqual(@as(usize, if (case.continue_recovery) 2 else 3), restored.history.len); if (!case.continue_recovery) { try std.testing.expect(restored.history[1] == .interrupted); try std.testing.expectEqualStrings("original request", restored.history[1].interrupted.user.text); try std.testing.expectEqualStrings("old partial answer", restored.history[1].interrupted.assistant.?); } const last = restored.history[restored.history.len - 1].assistant; try std.testing.expectEqualStrings(case.prompt, last.user.text); try std.testing.expectEqualStrings("new answer", last.assistant); try std.testing.expect(restored.recovery_checkpoint == null); }}
test "recovery continuation checks local checkpoint before credentials" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); const test_home = try TestAskHome.install(alloc, home); defer test_home.deinit();
const session_id = "completed-session"; var store = try session_store.Store.initFromHome(alloc, home, "/tmp/fx-test"); defer store.deinit(alloc); var state = try testAskDurableState(alloc, "/tmp/fx-test", session_id); defer state.deinit(alloc); var writable = try store.startWritableSession(alloc, state); writable.deinit(alloc);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testMissingKeyStartup, ); deps.initialize_session_stores = initializeSessionStoresDefault;
const exit_code = try runWithDeps( alloc, &.{ "--json", "--resume-id", session_id, "--continue-recovery" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); var parsed = try std.json.parseFromSlice( std.json.Value, alloc, stdout_capture.bytes.items, .{}, ); defer parsed.deinit(); try std.testing.expectEqualStrings( "NoPendingRecovery", parsed.value.object.get("error").?.string, );}
test "json run with session setup failure still emits final object" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.initialize_session_stores = testFailSessionStores;
const exit_code = try runWithDeps( alloc, &.{ "--json", "--resume-id", "corrupt-session", "hello" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "\"exit_code\":1") != null); try std.testing.expect(std.mem.find(u8, stdout_capture.bytes.items, "\"error\":\"InvalidSessionFormat\"") != null);}
test "missing API key returns before project context gathering" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
test_gather_project_context_calls = 0; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testMissingKeyStartup); deps.context_registry = test_count_context_registry;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqual(@as(usize, 0), test_gather_project_context_calls);}
test "fx ask forwards its catalog and deduplicates repeated discovery warnings" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
var deps = testPromptRunDeps( &stdout_capture, &stderr_capture, testPresentKeyTruncatedSkillCatalogStartup, ); deps.load_skills = testLoadTruncatedSkillsWithDiagnostic; deps.process_queued_prompt = testProcessQueuedPromptRepeatsSkillDiagnostic;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual( @as(usize, 1), std.mem.count(u8, stderr_capture.bytes.items, "skill discovery warning:"), );}
test "fx ask carries resolved auto mode and initial registry context into the queued prompt" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
TestContextRegistryFixture.reset(test_registry_static_context, 1); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.context_registry = test_cli_context_registry; deps.process_queued_prompt = TestContextRegistryFixture.process;
const exit_code = try runWithDeps(alloc, &.{ "--auto", "hello" }, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual(@as(usize, 1), TestContextRegistryFixture.gather_calls); try std.testing.expectEqual( PermissionMode.auto, TestContextRegistryFixture.transient_permission_mode orelse return error.TestExpectedEqual, );}
test "default fx ask passes canonical image paths as initial context targets" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var file = try tmp.dir.createFile(std.testing.io, "target.png", .{}); defer file.close(std.testing.io); try file.writeStreamingAll(std.testing.io, "\x89PNG\r\n\x1a\nrest"); } const image_path = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "target.png"); defer alloc.free(image_path); const image_path_z = try alloc.dupeZ(u8, image_path); defer alloc.free(image_path_z);
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
TestContextRegistryFixture.reset(test_registry_static_context, 1); TestContextRegistryFixture.expected_target_paths = &.{image_path}; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.context_registry = test_cli_context_registry; deps.process_queued_prompt = TestContextRegistryFixture.process;
const exit_code = try runWithDeps( alloc, &.{ "--image", image_path_z, "describe" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual(@as(usize, 1), TestContextRegistryFixture.gather_calls); try std.testing.expectEqual(@as(usize, 1), TestContextRegistryFixture.process_calls); try std.testing.expect(TestContextRegistryFixture.targets_match);}
test "default fx ask omits disabled registry context without gathering" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
TestContextRegistryFixture.reset(null, 0); var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyNoContextStartup); deps.context_registry = test_cli_context_registry; deps.process_queued_prompt = TestContextRegistryFixture.process;
const exit_code = try runWithDeps(alloc, &.{"hello"}, testConfig(), deps);
try std.testing.expectEqual(@as(u8, 0), exit_code); try std.testing.expectEqual(@as(usize, 0), TestContextRegistryFixture.gather_calls);}
test "default fx ask preserves project context gathering error mappings" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); const cases = [_]struct { err: context_contract.ProviderError, json: ?[]const u8, }{ .{ .err = error.OutOfMemory, .json = null }, .{ .err = error.NoSpaceLeft, .json = "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"NoSpaceLeft\"}\n" }, .{ .err = error.WriteFailed, .json = "{\"output\":\"\",\"final_output\":\"\",\"exit_code\":1,\"model\":\"\",\"session_id\":\"\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null},\"error\":\"WriteFailed\"}\n" }, };
for (cases) |case| { TestContextRegistryFixture.reset(null, 1); TestContextRegistryFixture.gather_error = case.err; var deps = testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup); deps.context_registry = test_cli_context_registry; deps.process_queued_prompt = TestContextRegistryFixture.process;
try std.testing.expectError(case.err, runWithDeps(alloc, &.{"hello"}, testConfig(), deps)); try std.testing.expectEqual(@as(usize, 1), TestContextRegistryFixture.gather_calls); try std.testing.expectEqual(@as(usize, 0), TestContextRegistryFixture.process_calls); try std.testing.expectEqual(@as(usize, 0), stdout_capture.bytes.items.len); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len);
TestContextRegistryFixture.reset(null, 1); TestContextRegistryFixture.gather_error = case.err; if (case.json) |expected_json| { const exit_code = try runWithDeps(alloc, &.{ "--json", "hello" }, testConfig(), deps); try std.testing.expectEqual(@as(u8, 1), exit_code); try std.testing.expectEqualStrings(expected_json, stdout_capture.bytes.items); } else { try std.testing.expectError( case.err, runWithDeps(alloc, &.{ "--json", "hello" }, testConfig(), deps), ); try std.testing.expectEqual(@as(usize, 0), stdout_capture.bytes.items.len); } try std.testing.expectEqual(@as(usize, 1), TestContextRegistryFixture.gather_calls); try std.testing.expectEqual(@as(usize, 0), TestContextRegistryFixture.process_calls); try std.testing.expectEqual(@as(usize, 0), stderr_capture.bytes.items.len); stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity(); }}
test "quiet suppresses streaming while quiet json captures final output" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc);
const quiet_exit = try runWithDeps(alloc, &.{ "--quiet", "hello" }, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup)); try std.testing.expectEqual(@as(u8, 0), quiet_exit); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);
stdout_capture.bytes.clearRetainingCapacity(); stderr_capture.bytes.clearRetainingCapacity();
const json_exit = try runWithDeps(alloc, &.{ "--quiet", "--json", "hello" }, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup)); try std.testing.expectEqual(@as(u8, 0), json_exit); try std.testing.expect(std.mem.startsWith(u8, stdout_capture.bytes.items, "{\"output\":\"assistant text\",\"final_output\":\"\",\"exit_code\":0,\"model\":\"model\",\"session_id\":\"")); try std.testing.expect(std.mem.endsWith(u8, stdout_capture.bytes.items, "\",\"steps\":0,\"tool_calls\":[],\"usage\":{\"input_tokens\":null,\"output_tokens\":null}}\n")); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "fx ask JSON recovery keeps stdout structured and reports progress on stderr" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); const deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptRecoveryLifecycle, );
const exit_code = try runWithDeps(alloc, &.{ "--json", "hello" }, testConfig(), deps); try std.testing.expectEqual(@as(u8, 0), exit_code); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); try std.testing.expectEqualStrings("assistant text", parsed.value.object.get("output").?.string); try std.testing.expect(parsed.value.object.get("recovery") == null); try std.testing.expectEqualStrings( "[notice] ⚠ Network interrupted · waiting for connection\n", stderr_capture.bytes.items, );}
test "fx ask JSON reports the consumed attempt after retry admission failure" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); const deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptRetryAdmissionFailure, );
const exit_code = try runWithDeps(alloc, &.{ "--json", "hello" }, testConfig(), deps); try std.testing.expectEqual(@as(u8, 1), exit_code); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); const recovery = parsed.value.object.get("recovery").?.object; try std.testing.expectEqualStrings("failed", recovery.get("state").?.string); try std.testing.expectEqual(@as(i64, 1), recovery.get("attempt").?.integer); try std.testing.expectEqual(@as(i64, 0), recovery.get("delay_seconds").?.integer); try std.testing.expectEqualStrings( "TestProviderSerializationFailed", parsed.value.object.get("error").?.string, ); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "retrying request in 4s") != null); try std.testing.expect(std.mem.find(u8, stderr_capture.bytes.items, "stopped after 1 attempt") != null);}
test "fx ask JSON preserves partial output on prompt failure" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptPartialThenReadFailed, ); deps.stdout_is_tty = TestTty.no;
const exit_code = try runWithDeps( alloc, &.{ "--json", "--no-save", "hello" }, testConfig(), deps, );
try std.testing.expectEqual(@as(u8, 1), exit_code); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, stdout_capture.bytes.items, .{}); defer parsed.deinit(); try std.testing.expectEqualStrings("partial résumé", parsed.value.object.get("output").?.string); try std.testing.expectEqual(@as(i64, 1), parsed.value.object.get("exit_code").?.integer); try std.testing.expectEqualStrings("ReadFailed", parsed.value.object.get("error").?.string); try std.testing.expectEqualStrings("model", parsed.value.object.get("model").?.string); try std.testing.expectEqualStrings("", parsed.value.object.get("session_id").?.string); try std.testing.expectEqual(@as(usize, 0), parsed.value.object.get("tool_calls").?.array.items.len); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "fx ask raw output still propagates prompt failure" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var deps = testPromptRunDepsWithProcess( &stdout_capture, &stderr_capture, testProcessQueuedPromptPartialThenReadFailed, ); deps.stdout_is_tty = TestTty.no;
try std.testing.expectError( error.ReadFailed, runWithDeps(alloc, &.{ "--no-save", "hello" }, testConfig(), deps), ); try std.testing.expectEqualStrings("partial résumé", stdout_capture.bytes.items); try std.testing.expectEqualStrings("", stderr_capture.bytes.items);}
test "CLI tagged stream routes source output rendering and diagnostics by mode" { const alloc = std.testing.allocator; const source_spans = [_][]const u8{ "# Heading\n", "\n**bold** [docs](https://example.com)\n", }; const source_output = "# Heading\n\n**bold** [docs](https://example.com)\n"; const rendered_span = "\x1b[1mbold\x1b[22m\n";
var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .raw;
const deps = agentRuntimeDeps(&ctx); try deps.push_text(deps.ctx, .{ .assistant_rendered = rendered_span }); for (source_spans) |span| try deps.push_text(deps.ctx, .{ .assistant_source = span });
try std.testing.expectEqualStrings(source_output, stdout_capture.bytes.items); try std.testing.expectEqualStrings("", stderr_capture.bytes.items); try std.testing.expectEqual(@as(usize, 0), ctx.assistant_output.items.len);
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 1, .call_id = "read_1" }, .reconciles_provisional_call_id = null, .tool_name = "read_file", .activity_kind = .read, }, }); try std.testing.expectEqual(@as(usize, 1), ctx.step_count); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 1, .call_id = "read_1" }, .text = "Reading src/main.zig", }, }); try std.testing.expectEqualStrings("Reading src/main.zig\n", stderr_capture.bytes.items); try deps.push_text(deps.ctx, .{ .assistant_source = "after **tool**\n" }); try deps.push_text(deps.ctx, .{ .operational = "diagnostic\n" });
try std.testing.expectEqualStrings( source_output ++ "\nafter **tool**\n", stdout_capture.bytes.items, ); try std.testing.expectEqualStrings( "Reading src/main.zig\ndiagnostic\n", stderr_capture.bytes.items, ); try std.testing.expectEqual(@as(usize, 1), ctx.step_count); try std.testing.expectEqual(@as(usize, 0), ctx.assistant_output.items.len);
var json_stdout_capture: TestCapture = .{}; defer json_stdout_capture.deinit(alloc); var json_stderr_capture: TestCapture = .{}; defer json_stderr_capture.deinit(alloc); var json_ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&json_stdout_capture, &json_stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer json_ctx.deinit(); json_ctx.output_mode = .json;
const json_deps = agentRuntimeDeps(&json_ctx); try json_deps.push_text(json_deps.ctx, .{ .assistant_rendered = rendered_span }); for (source_spans) |span| try json_deps.push_text(json_deps.ctx, .{ .assistant_source = span }); try json_deps.push_text(json_deps.ctx, .{ .operational = "diagnostic\n" }); const finished = try types.dupeFinishedPrompt(std.heap.c_allocator, .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("final answer"), } }, .terminal_outcome = .completed, }); try json_deps.push_event(json_deps.ctx, .{ .finish_prompt = finished });
try std.testing.expectEqualStrings(source_output, json_ctx.assistant_output.items); try std.testing.expectEqualStrings("final answer", json_ctx.final_output.items); try std.testing.expectEqualStrings("", json_stdout_capture.bytes.items); try std.testing.expectEqualStrings("diagnostic\n", json_stderr_capture.bytes.items);
const result = try takePromptRunResult(&json_ctx, alloc); defer result.deinit(alloc); const rendered = try renderFinalJsonResult(alloc, result); defer alloc.free(rendered); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, rendered, .{}); defer parsed.deinit(); try std.testing.expectEqualStrings(source_output, parsed.value.object.get("output").?.string); try std.testing.expectEqualStrings("final answer", parsed.value.object.get("final_output").?.string);}
test "CLI response restart preserves completed output and removes only the failed JSON preview" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .json; const deps = agentRuntimeDeps(&ctx); try deps.push_text(deps.ctx, .assistant_started); try deps.push_text(deps.ctx, .{ .assistant_source = "Completed tool commentary." }); try deps.push_text(deps.ctx, .assistant_started); try deps.push_text(deps.ctx, .{ .assistant_source = "DISCARDED partial" }); try deps.push_text(deps.ctx, .{ .assistant_restarted = "Restart notice\n" }); try std.testing.expectEqualStrings("Completed tool commentary.DISCARDED partial", ctx.assistant_output.items); try deps.push_text(deps.ctx, .assistant_started); try deps.push_text(deps.ctx, .{ .assistant_source = "Replacement." }); try std.testing.expectEqualStrings("Completed tool commentary.\n\nReplacement.", ctx.assistant_output.items); try std.testing.expectEqualStrings("Restart notice\n", stderr_capture.bytes.items); try std.testing.expectEqualStrings("", stdout_capture.bytes.items);
ctx.output_mode = .raw; try deps.push_text(deps.ctx, .{ .assistant_source = "partial" }); try deps.push_text(deps.ctx, .{ .assistant_restarted = "\n\nRestart notice\n\n" }); try deps.push_text(deps.ctx, .{ .assistant_source = "Replacement." }); try std.testing.expectEqualStrings("partial\n\nRestart notice\n\nReplacement.", stdout_capture.bytes.items);}
test "CLI keeps an unreplaced preview on failure and discards it on completion" { const alloc = std.testing.allocator; for ([_]types.TurnPresentationOutcome{ .paused, .failed, .interrupted, .completed }) |outcome| { var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .json; const deps = agentRuntimeDeps(&ctx); try deps.push_text(deps.ctx, .assistant_started); try deps.push_text(deps.ctx, .{ .assistant_source = "Completed commentary.\n\n" }); try deps.push_text(deps.ctx, .assistant_started); try deps.push_text(deps.ctx, .{ .assistant_source = "Retained preview." }); try deps.push_text(deps.ctx, .{ .assistant_restarted = "Restart\n" }); try deps.push_text(deps.ctx, .{ .assistant_restarted = "Restart\n" }); try deps.finalize_turn(deps.ctx, 1, outcome, null); try std.testing.expectEqualStrings( if (outcome == .completed) "Completed commentary.\n\n" else "Completed commentary.\n\nRetained preview.", ctx.assistant_output.items, ); try std.testing.expectEqual(@as(usize, 0), ctx.final_output.items.len); }}
test "CLI final output admits only completed assistant finish prompts" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .json; const deps = agentRuntimeDeps(&ctx);
const cases = [_]types.FinishedPrompt{ .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("failed partial"), } }, .terminal_outcome = .failed, }, .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("paused partial"), } }, .terminal_outcome = .paused, }, .{ .turn = .{ .interrupted = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("interrupted partial"), } }, .terminal_outcome = .interrupted, }, .{ .turn = .{ .compacted_summary = .{ .summary = @constCast("compacted partial"), .removed_turn_count = 1, .compaction_count = 1, } }, .terminal_outcome = .completed, }, .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("unqualified partial"), } }, }, };
for (cases) |source| { const owned = try types.dupeFinishedPrompt(std.heap.c_allocator, source); try deps.push_event(deps.ctx, .{ .finish_prompt = owned }); try std.testing.expectEqual(@as(usize, 0), ctx.final_output.items.len); }}
test "CLI command output completion terminates only an open display line" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .json;
try onCommandOutputChunk(&ctx, null, .stdout, "no-final"); try pushCommandOutputComplete(&ctx, null); try std.testing.expectEqualStrings("no-final\n", stderr_capture.bytes.items);
try onCommandOutputChunk(&ctx, null, .stdout, "with-final\n"); try pushCommandOutputComplete(&ctx, null); try pushCommandOutputComplete(&ctx, null); try std.testing.expectEqualStrings( "no-final\nwith-final\n", stderr_capture.bytes.items, );}
test "CLI nonterminal progress preserves distinct not-run labels without duplicates" { const alloc = std.testing.allocator; var stdout_capture: TestCapture = .{}; defer stdout_capture.deinit(alloc); var stderr_capture: TestCapture = .{}; defer stderr_capture.deinit(alloc); var ctx = AskContext.init( alloc, testConfig(), testPromptRunDeps(&stdout_capture, &stderr_capture, testPresentKeyStartup), "/tmp/workspace", ); defer ctx.deinit(); ctx.output_mode = .raw;
const deps = agentRuntimeDeps(&ctx); try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 1, .call_id = "deferred_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 1, .call_id = "deferred_write" }, .text = "Writing file", } }); try deps.push_tool_lifecycle(deps.ctx, .{ .terminal = .{ .id = .{ .turn_id = 1, .call_id = "deferred_write" }, .outcome = .{ .kind = .deferred, .summary = "Reading project instructions before continuing: Writing file" }, } }); try std.testing.expectEqualStrings("Writing file\n", stderr_capture.bytes.items);
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 2, .call_id = "retry_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 2, .call_id = "retry_write" }, .text = "Writing file", } }); try std.testing.expectEqualStrings("Writing file\n", stderr_capture.bytes.items); try deps.push_tool_lifecycle(deps.ctx, .{ .terminal = .{ .id = .{ .turn_id = 2, .call_id = "retry_write" }, .outcome = .{ .kind = .deferred, .summary = "Reading project instructions before continuing: Writing file" }, } });
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 3, .call_id = "final_retry_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 3, .call_id = "final_retry_write" }, .text = "Writing file", } }); try std.testing.expectEqualStrings("Writing file\n", stderr_capture.bytes.items);
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 4, .call_id = "resolved_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 4, .call_id = "resolved_write" }, .text = "Writing build/pkg/proof.txt", } }); try std.testing.expectEqualStrings( "Writing file\nWriting build/pkg/proof.txt\n", stderr_capture.bytes.items, );
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 5, .call_id = "invalid_fetch" }, .reconciles_provisional_call_id = null, .tool_name = "web_fetch", .activity_kind = .read, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 5, .call_id = "invalid_fetch" }, .text = "Fetching", } }); try std.testing.expectEqualStrings( "Writing file\nWriting build/pkg/proof.txt\n", stderr_capture.bytes.items, );
try deps.push_tool_lifecycle(deps.ctx, .{ .provisional = .{ .id = .{ .turn_id = 6, .call_id = "streamed_read" }, .tool_name = "read_file", .activity_kind = .read, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 6, .call_id = "streamed_read" }, .text = "Reading streamed input", } }); try std.testing.expectEqualStrings( "Writing file\nWriting build/pkg/proof.txt\nReading streamed input\n", stderr_capture.bytes.items, );
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 7, .call_id = "legacy_deferred_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 7, .call_id = "legacy_deferred_write" }, .text = "Writing legacy file", } }); try deps.push_tool_lifecycle(deps.ctx, .{ .terminal = .{ .id = .{ .turn_id = 7, .call_id = "legacy_deferred_write" }, .outcome = .{ .kind = .denied, .summary = "Not executed: Writing legacy file" }, } }); try std.testing.expectEqualStrings( "Writing file\nWriting build/pkg/proof.txt\nReading streamed input\nWriting legacy file\n", stderr_capture.bytes.items, );
try deps.push_tool_lifecycle(deps.ctx, .{ .authoritative_started = .{ .id = .{ .turn_id = 8, .call_id = "legacy_retry_write" }, .reconciles_provisional_call_id = null, .tool_name = "write_file", .activity_kind = .write, } }); try deps.push_tool_lifecycle(deps.ctx, .{ .progress = .{ .id = .{ .turn_id = 8, .call_id = "legacy_retry_write" }, .text = "Writing legacy file", } }); try std.testing.expectEqualStrings( "Writing file\nWriting build/pkg/proof.txt\nReading streamed input\nWriting legacy file\n", stderr_capture.bytes.items, );}
test "CLI output mode selection preserves machine output precedence" { try std.testing.expectEqual(OutputMode.json, selectOutputMode(true, true, true, true)); try std.testing.expectEqual(OutputMode.quiet, selectOutputMode(true, false, true, false)); try std.testing.expectEqual(OutputMode.raw, selectOutputMode(false, false, false, true)); try std.testing.expectEqual(OutputMode.terminal, selectOutputMode(false, false, true, false)); try std.testing.expectEqual(OutputMode.terminal_no_color, selectOutputMode(false, false, true, true));}