Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
212 kB · 5690 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691import { homedir } from "node:os";import * as path from "node:path";import type { AgentMessage } from "@earendil-works/pi-agent-core";import { type AutocompleteProvider, CombinedAutocompleteProvider, type Component, Container, getKeybindings, resetCapabilitiesCache, setCapabilities, setKeybindings, type TUI, visibleWidth,} from "@earendil-works/pi-tui";import { beforeAll, describe, expect, test, vi } from "vitest";import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.js";import { formatNoModelsAvailableMessage } from "../src/core/auth-guidance.js";import { type AuthStatus, AuthStorage } from "../src/core/auth-storage.js";import type { AgentCronJob } from "../src/core/cron-jobs.js";import type { AutocompleteProviderFactory } from "../src/core/extensions/types.js";import { emptyGoalState, type GoalState } from "../src/core/goals.js";import { KeybindingsManager } from "../src/core/keybindings.js";import { createSessionSlashCommandMessage, createSessionSlashCommandResultMessage } from "../src/core/messages.js";import type { ModelRegistry } from "../src/core/model-registry.js";import { PRIME_INFERENCE_PROVIDER_ID } from "../src/core/prime-inference-auth.js";import type { PromptCacheSnapshot } from "../src/core/prompt-cache.js";import { SettingsManager } from "../src/core/settings-manager.js";import { emptyUsage } from "../src/core/usage.js";import { InProcessAgentConnection } from "../src/modes/agent-connection/in-process-agent-connection.js";import type { AgentConnectionExtensionUiRequest, AgentConnectionExtensionUiResponse, AgentConnectionHeartbeat, AgentConnectionModel, AgentConnectionModelCatalog, AgentConnectionResourceDiagnostic, AgentConnectionResourceSnapshot, AgentConnectionSessionContext, AgentConnectionSessionEvent, AgentConnectionSnapshot, AgentConnectionSourceInfo, AgentConnectionState,} from "../src/modes/agent-connection/types.js";import { AgentConnectionPromptAdmissionError } from "../src/modes/agent-connection/types.js";import { AgentActivityTracker } from "../src/modes/interactive/agent-activity.js";import type { AuthenticationResult } from "../src/modes/interactive/auth-flows.js";import { AgentMessageComponent } from "../src/modes/interactive/components/agent-message.js";import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.js";import type { ConfigurationMenuComponent } from "../src/modes/interactive/components/configuration-menu.js";import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.js";import type { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";import { formatSplashCwd, InteractiveMode, truncatePathMiddle } from "../src/modes/interactive/interactive-mode.js";import { ClientPromptStashStore, type PromptStashState } from "../src/modes/interactive/prompt-stash-state.js";import { QueueSelection } from "../src/modes/interactive/queue-selection.js";import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
function renderLastLine(container: Container, width = 120): string { const last = container.children[container.children.length - 1]; if (!last) return ""; return last.render(width).join("\n");}
function renderAll(container: Container, width = 120): string { return container.children.flatMap((child) => child.render(width)).join("\n");}
function normalizeRenderedOutput(container: Container, width = 220): string { return renderAll(container, width) .replace(/\u001b\[[0-9;]*m/g, "") .replace(/\\/g, "/") .split("\n") .map((line) => line.replace(/\s+$/g, "")) .join("\n") .trim();}
function createDeferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(error: unknown): void;} { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise<T>((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); return { promise, resolve, reject };}
async function flushAsyncWork(): Promise<void> { await new Promise((resolve) => setTimeout(resolve, 0));}
function createConnectionState(overrides: Partial<AgentConnectionState> = {}): AgentConnectionState { return { activeSessionId: "active-1", cwd: "/tmp/project", thinkingLevel: "medium", serviceTier: "default", availableThinkingLevels: ["minimal", "low", "medium", "high", "xhigh"], isStreaming: false, isCompacting: false, isBashRunning: false, retryAttempt: 0, steeringMode: "all", followUpMode: "all", sessionId: "session-1", leafId: null, autoCompactionEnabled: true, messageCount: 0, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, compactionCount: 0, goal: emptyGoalState(), scopedModels: [], activeToolNames: ["ipython"], contextUsage: undefined, ...overrides, };}
describe("InteractiveMode update notifications", () => { beforeAll(() => { initTheme("dark"); });
test("shows the slash update command in one compact line", () => { const chatContainer = new Container(); const fakeThis = { chatContainer, ui: { requestRender: vi.fn() }, } as unknown as InteractiveMode;
InteractiveMode.prototype.showNewVersionNotification.call(fakeThis, "1.2.3");
const output = normalizeRenderedOutput(chatContainer); expect(chatContainer.children).toHaveLength(1); expect(output).toBe("Update available: v1.2.3. Run /update"); });
test("shows package updates in one compact line", () => { const chatContainer = new Container(); const fakeThis = { chatContainer, ui: { requestRender: vi.fn() }, } as unknown as InteractiveMode;
InteractiveMode.prototype.showPackageUpdateNotification.call(fakeThis, ["npm:@foo/bar", "npm:@baz/qux"]);
const output = normalizeRenderedOutput(chatContainer); expect(chatContainer.children).toHaveLength(1); expect(output).toBe("Package updates available: npm:@foo/bar, npm:@baz/qux. Run /update --extensions"); });});
type ExtensionFixture = { path: string; sourceInfo?: AgentConnectionSourceInfo;};
describe("InteractiveMode.showStatus", () => { beforeAll(() => { // showStatus uses the global theme instance initTheme("dark"); });
test("coalesces immediately-sequential status messages", () => { const fakeThis: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, lastStatusSpacer: undefined, lastStatusText: undefined, };
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); expect(fakeThis.chatContainer.children).toHaveLength(2); expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE");
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); // second status updates the previous line instead of appending expect(fakeThis.chatContainer.children).toHaveLength(2); expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE"); });
test("appends a new status line if something else was added in between", () => { const fakeThis: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, lastStatusSpacer: undefined, lastStatusText: undefined, };
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); expect(fakeThis.chatContainer.children).toHaveLength(2);
// Something else gets added to the chat in between status updates fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} }); expect(fakeThis.chatContainer.children).toHaveLength(3);
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); // adds spacer + text expect(fakeThis.chatContainer.children).toHaveLength(5); expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); });});
type RenderSessionContextHarness = { pendingTools: Map<string, ToolExecutionComponent>; ipythonToolComponents: Map<string, unknown>; lateIpythonSentAgentMessages: Map<string, unknown[]>; toolOutputExpanded: boolean; chatContainer: Container; editor: { addToHistory?: (text: string) => void }; footer: { invalidate: () => void }; updateEditorBorderColor: () => void; resetPendingToolState: () => void; preloadToolDefinitions: (toolNames: string[]) => Promise<void>; settingsManager: { getShowImages: () => boolean }; getCachedToolDefinition: () => undefined; getCurrentCwd: () => string; getRetryAttempt: () => number; ui: { requestRender: () => void }; addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => void; connectionState?: AgentConnectionState;};
type RenderSessionContextOptions = { updateFooter?: boolean; populateHistory?: boolean; clearChat?: boolean; limitTranscript?: boolean;};
const renderSessionContext = ( InteractiveMode.prototype as unknown as { renderSessionContext( this: RenderSessionContextHarness, sessionContext: AgentConnectionSessionContext, options?: RenderSessionContextOptions, ): Promise<void>; }).renderSessionContext;
function createRenderSessionContextHarness(overrides: Partial<RenderSessionContextHarness> = {}): { harness: RenderSessionContextHarness; chatContainer: Container; addMessageToChat: ReturnType<typeof vi.fn>; addToHistory: ReturnType<typeof vi.fn>;} { const chatContainer = overrides.chatContainer ?? new Container(); const addMessageToChat = vi.fn(() => { chatContainer.addChild({ render: () => ["assistant"], invalidate: () => {} }); }); const addToHistory = vi.fn(); const harness: RenderSessionContextHarness = { pendingTools: new Map<string, ToolExecutionComponent>(), ipythonToolComponents: new Map<string, unknown>(), lateIpythonSentAgentMessages: new Map<string, unknown[]>(), toolOutputExpanded: false, chatContainer, editor: { addToHistory }, footer: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), resetPendingToolState: vi.fn(), preloadToolDefinitions: vi.fn(async () => {}), settingsManager: { getShowImages: () => true }, getCachedToolDefinition: () => undefined, getCurrentCwd: () => process.cwd(), getRetryAttempt: () => 0, ui: { requestRender: vi.fn() }, addMessageToChat, ...overrides, }; Object.setPrototypeOf(harness, InteractiveMode.prototype); return { harness, chatContainer, addMessageToChat, addToHistory };}
function userMessage(content: string, timestamp: number): Extract<AgentMessage, { role: "user" }> { return { role: "user", content, timestamp };}
function toolCallMessage(id: string, name: string): Extract<AgentMessage, { role: "assistant" }> { return { role: "assistant", content: [{ type: "toolCall", name, id, arguments: {} }], api: "openai-responses", provider: "openai", model: "test-model", usage: emptyUsage(), stopReason: "toolUse", timestamp: 0, };}
function toolResultMessage( id: string, name: string, content: Extract<AgentMessage, { role: "toolResult" }>["content"],): Extract<AgentMessage, { role: "toolResult" }> { return { role: "toolResult", toolCallId: id, toolName: name, content, isError: false, timestamp: 0 };}
async function renderMessages( harness: RenderSessionContextHarness, messages: AgentMessage[], options?: RenderSessionContextOptions,): Promise<void> { await renderSessionContext.call( harness, { messages, thinkingLevel: "medium", serviceTier: "default", model: null }, options, );}
describe("InteractiveMode.renderSessionContext", () => { beforeAll(() => { initTheme("dark"); });
test("does not replay historical tool result image payloads", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); try { const chatContainer = new Container(); const ipythonToolComponents = new Map([["stale-tool", {}]]); const lateIpythonSentAgentMessages = new Map([["stale-tool", []]]); const fakeThis: any = { pendingTools: new Map(), ipythonToolComponents, lateIpythonSentAgentMessages, toolOutputExpanded: false, chatContainer, footer: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), resetPendingToolState: vi.fn(), preloadToolDefinitions: vi.fn(async () => {}), settingsManager: { getShowImages: () => true, }, getCachedToolDefinition: () => undefined, getCurrentCwd: () => process.cwd(), getRetryAttempt: () => 0, ui: { requestRender: vi.fn() }, addMessageToChat: vi.fn(() => { chatContainer.addChild({ render: () => ["assistant"], invalidate: () => {} }); }), }; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype);
await (InteractiveMode as any).prototype.renderSessionContext.call(fakeThis, { messages: [ { role: "assistant", content: [{ type: "toolCall", name: "custom_tool", id: "tool-1", arguments: {} }], }, { role: "toolResult", toolCallId: "tool-1", content: [{ type: "image", data: "AAAA", mimeType: "image/png" }], isError: false, }, ], thinkingLevel: "medium", model: null, });
const rendered = renderAll(chatContainer); expect(rendered).not.toContain("\x1b_G"); expect(ipythonToolComponents.size).toBe(0); expect(lateIpythonSentAgentMessages.size).toBe(0); } finally { resetCapabilitiesCache(); } });
test("keeps pending history tool calls metadata-only when live results arrive", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); try { const chatContainer = new Container(); const pendingTools = new Map<string, ToolExecutionComponent>(); const fakeThis: any = { pendingTools, ipythonToolComponents: new Map(), lateIpythonSentAgentMessages: new Map(), toolOutputExpanded: false, chatContainer, footer: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), resetPendingToolState: vi.fn(), preloadToolDefinitions: vi.fn(async () => {}), settingsManager: { getShowImages: () => true, }, getCachedToolDefinition: () => undefined, getCurrentCwd: () => process.cwd(), getRetryAttempt: () => 0, ui: { requestRender: vi.fn() }, addMessageToChat: vi.fn(() => { chatContainer.addChild({ render: () => ["assistant"], invalidate: () => {} }); }), }; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype);
await (InteractiveMode as any).prototype.renderSessionContext.call(fakeThis, { messages: [ { role: "assistant", content: [{ type: "toolCall", name: "custom_tool", id: "tool-1", arguments: {} }], }, ], thinkingLevel: "medium", model: null, });
const component = pendingTools.get("tool-1"); expect(component).toBeDefined(); component!.updateResult({ content: [{ type: "image", data: "AAAA", mimeType: "image/png" }], isError: false });
const rendered = normalizeRenderedOutput(chatContainer); expect(rendered).not.toContain("\x1b_G"); expect(rendered).toContain("╰─ [image/png · 800×600]"); } finally { resetCapabilitiesCache(); } });
test("renders only the recent tail for very long initial transcripts", async () => { const { harness, chatContainer, addMessageToChat } = createRenderSessionContextHarness(); const messages = Array.from({ length: 405 }, (_, index) => userMessage(`message ${index}`, index));
await renderMessages(harness, messages, { limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledTimes(400); expect(addMessageToChat.mock.calls[0]?.[0]).toMatchObject({ content: "message 5" }); expect(renderAll(chatContainer)).toContain("Showing latest 400 of 405 messages for faster open."); });
test("keeps equal-timestamp legacy messages after the compaction summary", async () => { const { harness, addMessageToChat } = createRenderSessionContextHarness(); const earlier = userMessage("earlier", 4); const summary = { role: "compactionSummary", summary: "legacy summary", tokensBefore: 123, timestamp: 5, } as AgentMessage; const equalLater = userMessage("equal later", 5);
await renderMessages(harness, [summary, earlier, equalLater]); expect(addMessageToChat.mock.calls.map((call) => call[0])).toEqual([earlier, summary, equalLater]); });
test("orders the compaction summary at its exact boundary before bounding the initial transcript", async () => { const { harness, addMessageToChat } = createRenderSessionContextHarness(); const retained = Array.from({ length: 5 }, (_, index) => userMessage(`retained ${index}`, 5)); const summary = { role: "compactionSummary", summary: "summary", tokensBefore: 123, retainedMessageCount: retained.length, timestamp: 5, } as AgentMessage; const later = Array.from({ length: 399 }, (_, index) => userMessage(`later ${index}`, 5));
await renderMessages(harness, [summary, ...retained, ...later], { limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledTimes(400); expect(addMessageToChat.mock.calls[0]?.[0]).toBe(summary); expect(addMessageToChat.mock.calls[1]?.[0]).toMatchObject({ content: "later 0" }); });
test("preserves the full transcript when rebuilding a cleared transcript", async () => { const { harness, chatContainer, addMessageToChat } = createRenderSessionContextHarness(); chatContainer.addChild({ render: () => ["old transcript"], invalidate: () => {} }); const messages = Array.from({ length: 405 }, (_, index) => userMessage(`message ${index}`, index));
await renderMessages(harness, messages, { clearChat: true });
expect(addMessageToChat).toHaveBeenCalledTimes(405); expect(addMessageToChat.mock.calls[0]?.[0]).toMatchObject({ content: "message 0" }); expect(renderAll(chatContainer)).not.toContain("old transcript"); expect(renderAll(chatContainer)).not.toContain("for faster open"); });
test("populates editor history from the full transcript when initial rendering is capped", async () => { const { harness, addMessageToChat, addToHistory } = createRenderSessionContextHarness(); const messages = Array.from({ length: 405 }, (_, index) => userMessage(`message ${index}`, index));
await renderMessages(harness, messages, { populateHistory: true, limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledTimes(400); expect(addToHistory).toHaveBeenCalledTimes(405); expect(addToHistory.mock.calls[0]?.[0]).toBe("message 0"); expect(addToHistory.mock.calls.at(-1)?.[0]).toBe("message 404"); });
test("excludes legacy heartbeat prompts from editor history", async () => { const timestamp = Date.parse("2026-01-01T00:05:00.000Z"); const heartbeat = { id: "heartbeat-1", status: "active", source: "heartbeat", activeSessionId: "active-1", sessionId: "session-1", sessionFile: "/tmp/session.jsonl", cwd: "/tmp/project", prompt: "check whether there is follow-up work", schedule: { kind: "interval", expression: "every 5m", intervalMs: 300_000 }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", lastRunAt: "2026-01-01T00:05:00.000Z", runCount: 1, } satisfies AgentCronJob; const { harness, addToHistory } = createRenderSessionContextHarness({ connectionState: createConnectionState({ heartbeat }), });
await renderMessages( harness, [ userMessage(heartbeat.prompt, timestamp), userMessage(heartbeat.prompt, timestamp + 86_400_000), userMessage("ordinary prompt", timestamp + 86_400_001), ], { populateHistory: true }, );
expect(addToHistory).toHaveBeenCalledTimes(2); expect(addToHistory).toHaveBeenNthCalledWith(1, heartbeat.prompt); expect(addToHistory).toHaveBeenCalledWith("ordinary prompt"); });
test("trims capped initial renders past orphaned tool results", async () => { const { harness, chatContainer, addMessageToChat } = createRenderSessionContextHarness(); const messages = [ toolCallMessage("tool-old", "old_tool"), toolResultMessage("tool-old", "old_tool", [{ type: "text", text: "old result" }]), ...Array.from({ length: 399 }, (_, index) => userMessage(`message ${index}`, index)), ];
await renderMessages(harness, messages, { limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledTimes(399); expect(addMessageToChat.mock.calls[0]?.[0]).toMatchObject({ content: "message 0" }); expect(renderAll(chatContainer)).toContain("Showing latest 399 of 401 messages for faster open."); });
test("omits a trailing orphaned tool result without dropping the recent tail", async () => { const { harness, chatContainer, addMessageToChat } = createRenderSessionContextHarness(); const messages = [ toolCallMessage("tool-old", "old_tool"), ...Array.from({ length: 399 }, (_, index) => userMessage(`message ${index}`, index)), toolResultMessage("tool-old", "old_tool", [{ type: "text", text: "old result" }]), ];
await renderMessages(harness, messages, { limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledTimes(399); expect(addMessageToChat.mock.calls[0]?.[0]).toMatchObject({ role: "assistant" }); expect(addMessageToChat.mock.calls[1]?.[0]).toMatchObject({ content: "message 1" }); expect(addMessageToChat.mock.calls.at(-1)?.[0]).toMatchObject({ content: "message 398" }); expect(renderAll(chatContainer)).toContain("Showing latest 400 of 401 messages for faster open."); });
test("keeps a bounded tool-call context when the recent tail contains only tool results", async () => { const { harness, chatContainer, addMessageToChat } = createRenderSessionContextHarness(); const toolCallIds = Array.from({ length: 400 }, (_, index) => `tool-${index}`); const assistantMessage: Extract<AgentMessage, { role: "assistant" }> = { ...toolCallMessage(toolCallIds[0]!, "custom_tool"), content: toolCallIds.map((id) => ({ type: "toolCall" as const, name: "custom_tool", id, arguments: {} })), }; const messages = [ assistantMessage, ...toolCallIds.map((id) => toolResultMessage(id, "custom_tool", [{ type: "text", text: `result ${id}` }])), ];
await renderMessages(harness, messages, { limitTranscript: true });
expect(addMessageToChat).toHaveBeenCalledOnce(); expect(addMessageToChat.mock.calls[0]?.[0]).toMatchObject({ role: "assistant" }); expect(harness.preloadToolDefinitions).toHaveBeenCalledWith(Array(399).fill("custom_tool")); expect(renderAll(chatContainer)).toContain("Showing latest 400 of 401 messages for faster open."); });});
type SubmitHandlerHarness = { defaultEditor: { onSubmit?: (text: string) => Promise<void>; }; editor: { getText: () => string; getExpandedText?: () => string; getPasteSnapshot?: () => unknown; setText: (text: string) => void; addToHistory?: (text: string) => void; onSubmit?: (text: string) => Promise<void>; }; promptStash?: { text: string }; promptStashState: PromptStashState; pastedImages: Map<number, unknown>; getPromptStashImages: (text: string) => readonly (readonly [number, unknown])[]; retainStartupPromptDrafts?: (prompts: readonly { text: string; images?: readonly unknown[] }[]) => void; nextImageMarkerId?: number; admitPendingStartupPrompts?: () => Promise<"admitted" | "retained" | "lifecycle-cancelled">; isShuttingDown?: boolean; returnToAgentsViewRequested?: boolean; submittedInputBehavior: "steer" | "followUp"; latestEditorPromptStash?: unknown; pendingSubmittedPromptStash?: unknown; snapshotPromptStash: (text: string) => unknown; retainSubmittedDraft: (stash: unknown, submissionGeneration: number) => void; inputSubmissionGeneration: number; inputSubmissionsPending: number; pendingPromptStashReleases: { sessionId: string; state: unknown }[]; retainedSubmissionGenerations: WeakMap<object, number>; releasePromptStashSession?: () => void; flushPendingBashComponents: () => void; collectImagesFor: () => unknown[]; updatePendingMessagesDisplay: () => void; ui: { requestRender: () => void }; clearSideQuestion: () => void; clearShortcutGuide: () => void; showWarning: (message: string) => void; showError: (message: string) => void; isBashRunning: () => boolean; patchConnectionState: (patch: Record<string, unknown>) => void; requestAgentsView: () => Promise<void>; handleResumeCommand: (args: string) => Promise<void>; agentConnection: { prompt: (message: string) => Promise<void>; executeBash: (command: string, options?: { excludeFromContext?: boolean }) => Promise<void>; getState: () => Promise<{ isBashRunning: boolean }>; };};
function createSubmitHandlerHarness(overrides: Partial<SubmitHandlerHarness> = {}): SubmitHandlerHarness { const fakeThis: SubmitHandlerHarness = { defaultEditor: {}, editor: { getText: vi.fn(() => ""), setText: vi.fn(), addToHistory: vi.fn() }, clearSideQuestion: vi.fn(), clearShortcutGuide: vi.fn(), showWarning: vi.fn(), showError: vi.fn(), isBashRunning: () => false, patchConnectionState: vi.fn(), promptStashState: {}, requestAgentsView: vi.fn(async () => {}), handleResumeCommand: vi.fn(async () => {}), promptStash: undefined, pastedImages: new Map(), getPromptStashImages: vi.fn(() => []), snapshotPromptStash: vi.fn((text) => ({ text })), retainSubmittedDraft: ( InteractiveMode.prototype as unknown as { retainSubmittedDraft(stash: unknown, generation: number): void } ).retainSubmittedDraft, retainStartupPromptDrafts: ( InteractiveMode.prototype as unknown as { retainStartupPromptDrafts(prompts: readonly { text: string; images?: readonly unknown[] }[]): void; } ).retainStartupPromptDrafts, nextImageMarkerId: 1, submittedInputBehavior: "steer", inputSubmissionGeneration: 0, inputSubmissionsPending: 0, pendingPromptStashReleases: [], retainedSubmissionGenerations: new WeakMap(), flushPendingBashComponents: vi.fn(), collectImagesFor: vi.fn(() => []), updatePendingMessagesDisplay: vi.fn(), ui: { requestRender: vi.fn() }, agentConnection: { prompt: vi.fn(async () => {}), executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, ...overrides, }; ( InteractiveMode.prototype as unknown as { setupEditorSubmitHandler(this: SubmitHandlerHarness): void; } ).setupEditorSubmitHandler.call(fakeThis); return fakeThis;}
describe("InteractiveMode working timer", () => { type InitialTimerHarness = { turnStartedAt: number | undefined; workingStartedAt: number | undefined; agentConnection: { getInitialSnapshot(): Promise<AgentConnectionSnapshot> }; isAgentStreaming(): boolean; updateWorkingLoaderMessage: ReturnType<typeof vi.fn>; renderInitialMessages(): Promise<void>; stopWorkingLoader: ReturnType<typeof vi.fn>; createWorkingLoader: ReturnType<typeof vi.fn>; statusContainer: { addChild: ReturnType<typeof vi.fn> }; startWorkingTimer: ReturnType<typeof vi.fn>; startFeatureHintPresentation: ReturnType<typeof vi.fn>; };
function createInitialTimerHarness(snapshot: AgentConnectionSnapshot, turnStartedAt = 1): InitialTimerHarness { let streaming = false; return Object.assign(Object.create(InteractiveMode.prototype), { turnStartedAt, workingStartedAt: turnStartedAt, agentConnection: { getInitialSnapshot: vi.fn(async () => snapshot) }, getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: snapshot.messages, thinkingLevel: "medium", serviceTier: "default", model: null, })), seedSubagentSummary: vi.fn(), applyConnectionStateSnapshot: vi.fn((state: AgentConnectionState) => { streaming = state.isStreaming; }), isAgentStreaming: () => streaming, updateWorkingLoaderMessage: vi.fn(), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(async () => {}), showStatus: vi.fn(), stopWorkingLoader: vi.fn(), createWorkingLoader: vi.fn(() => ({})), statusContainer: { addChild: vi.fn() }, startWorkingTimer: vi.fn(), startFeatureHintPresentation: vi.fn(), }); }
function customStarter(customType: "agent_message" | "heartbeat_prompt", timestamp: number): AgentMessage { return { role: "custom", customType, content: "Continue.", display: true, details: customType === "agent_message" ? { id: `agentmsg_${timestamp}`, message: "Continue." } : {}, timestamp, }; }
test("restores the first active-run starter instead of a steering message", async () => { const harness = createInitialTimerHarness({ state: createConnectionState({ isStreaming: true }), messages: [ userMessage("Start the task.", 100), { ...toolCallMessage("tool-1", "ipython"), timestamp: 200 }, userMessage("Also check this.", 300), ], });
await harness.renderInitialMessages();
expect(harness.turnStartedAt).toBe(100); expect(harness.workingStartedAt).toBe(100); expect(harness.updateWorkingLoaderMessage).toHaveBeenCalledOnce(); });
test.each([ ["agent-session", customStarter("agent_message", 100)], ["heartbeat", customStarter("heartbeat_prompt", 200)], ])("restores a %s run starter", async (_kind, starter) => { const harness = createInitialTimerHarness({ state: createConnectionState({ isStreaming: true }), messages: [starter], });
await harness.renderInitialMessages();
expect(harness.turnStartedAt).toBe(starter.timestamp); expect(harness.workingStartedAt).toBe(starter.timestamp); });
test.each([ ["a non-streaming snapshot", false, [userMessage("Old prompt.", 200)]], ["a streaming snapshot without a starter", true, [{ ...toolCallMessage("tool-1", "ipython"), timestamp: 200 }]], ])("clears a stale anchor for %s and falls back to the loader start", async (_name, isStreaming, messages) => { const harness = createInitialTimerHarness({ state: createConnectionState({ isStreaming }), messages }, 100);
await harness.renderInitialMessages(); const now = vi.spyOn(Date, "now").mockReturnValue(500); try { ( InteractiveMode.prototype as unknown as { startWorkingLoader(this: InitialTimerHarness): void } ).startWorkingLoader.call(harness); } finally { now.mockRestore(); }
expect(harness.turnStartedAt).toBeUndefined(); expect(harness.workingStartedAt).toBe(500); });});
describe("InteractiveMode submit handling", () => { test.each(["normal Enter", "installed custom editor"])("captures exact rich state for %s", async () => { const image = { type: "image", data: "base64", mimeType: "image/png" }; const pasteSnapshot = { pastes: [[1, "expanded paste"]] as const, pasteCounter: 2 }; const rawText = "draft [paste #1] [image #7]"; let editorText = rawText; const editor = { getText: () => editorText, getExpandedText: () => "draft expanded paste [image #7]", getPasteSnapshot: () => pasteSnapshot, setText: (text: string) => { editorText = text; }, onSubmit: undefined as ((text: string) => Promise<void>) | undefined, }; const fakeThis = createSubmitHandlerHarness({ editor, pastedImages: new Map([[7, image]]), getPromptStashImages: () => [[7, image]], admitPendingStartupPrompts: vi.fn(async (): Promise<"lifecycle-cancelled"> => "lifecycle-cancelled"), }); delete fakeThis.promptStash; fakeThis.snapshotPromptStash = ( InteractiveMode.prototype as unknown as { snapshotPromptStash(text: string): unknown } ).snapshotPromptStash.bind({ editor, snapshotPromptStashFrom: ( InteractiveMode.prototype as unknown as { snapshotPromptStashFrom(editor: unknown, text: string): unknown; } ).snapshotPromptStashFrom, getPromptStashImages: fakeThis.getPromptStashImages, } as never); fakeThis.latestEditorPromptStash = fakeThis.snapshotPromptStash(rawText); editor.onSubmit = fakeThis.defaultEditor.onSubmit;
// Both pi-tui's normal editor and an installed extension editor call their // wired onSubmit only after clearing their own state. editorText = ""; await editor.onSubmit?.("draft expanded paste [image #7]");
expect(fakeThis.promptStashState.stash).toEqual({ text: rawText, expandedText: "draft expanded paste [image #7]", pasteSnapshot, images: [[7, image]], }); });
test.each([true, false])( "custom editor replacement preserves the submitted rich draft through lifecycle cancellation (snapshot restore: %s)", async (supportsSnapshotRestore) => { initTheme("dark"); const image = { type: "image", data: "base64", mimeType: "image/png" }; const pasteSnapshot = { pastes: [[5, "expanded paste"]] as const, pasteCounter: 6 }; const rawText = "draft [paste #5] [image #7]"; const expandedText = "draft expanded paste [image #7]"; let oldText = rawText; const oldEditor = { onSubmit: undefined as ((text: string) => Promise<void>) | undefined, onChange: undefined as ((text: string) => void) | undefined, getText: () => oldText, getExpandedText: () => expandedText, getPasteSnapshot: () => pasteSnapshot, setText: (text: string) => { oldText = text; }, handleInput: vi.fn(), render: () => [], }; let customText = ""; let restoredSnapshot: unknown; const customEditor = { getText: () => customText, getExpandedText: () => (restoredSnapshot ? expandedText : customText), getPasteSnapshot: () => restoredSnapshot, restorePasteSnapshot: supportsSnapshotRestore ? (snapshot: unknown) => { restoredSnapshot = snapshot; } : undefined, setText: (text: string) => { customText = text; customEditor.onChange?.(text); }, handleInput: vi.fn(), render: () => [], onSubmit: undefined as ((text: string) => Promise<void>) | undefined, onChange: undefined as ((text: string) => void) | undefined, }; const lifecycleBarrier = createDeferred<"admitted" | "lifecycle-cancelled">(); const promptStashState: PromptStashState = {}; const fakeThis = createSubmitHandlerHarness({ defaultEditor: oldEditor, editor: oldEditor, promptStashState, pastedImages: new Map([[7, image]]), getPromptStashImages: (text) => (text.includes("[image #7]") ? [[7, image]] : []), admitPendingStartupPrompts: () => lifecycleBarrier.promise, }); Object.assign(fakeThis, { editorContainer: new Container(), ui: { setFocus: vi.fn(), requestRender: vi.fn() }, keybindings: {}, autocompleteProvider: undefined, showStatus: vi.fn(), }); Object.defineProperty(fakeThis, "promptStash", { configurable: true, get: () => promptStashState.stash, set: (stash) => { promptStashState.stash = stash; }, }); fakeThis.snapshotPromptStash = ( InteractiveMode.prototype as unknown as { snapshotPromptStash(text: string): unknown } ).snapshotPromptStash.bind(fakeThis as never); ( fakeThis as unknown as { snapshotPromptStashFrom(editor: unknown, text: string): unknown } ).snapshotPromptStashFrom = ( InteractiveMode.prototype as unknown as { snapshotPromptStashFrom(editor: unknown, text: string): unknown; } ).snapshotPromptStashFrom; ( InteractiveMode.prototype as unknown as { setupEditorSubmitHandler(this: SubmitHandlerHarness): void } ).setupEditorSubmitHandler.call(fakeThis); oldEditor.onChange = (text: string) => { if (text.length > 0) fakeThis.latestEditorPromptStash = fakeThis.snapshotPromptStash(text); };
( InteractiveMode.prototype as unknown as { setCustomEditorComponent(this: unknown, factory: () => unknown): void; } ).setCustomEditorComponent.call(fakeThis, () => customEditor);
expect(customText).toBe(supportsSnapshotRestore ? rawText : expandedText); expect(restoredSnapshot).toBe(supportsSnapshotRestore ? pasteSnapshot : undefined); customEditor.setText(""); const submission = customEditor.onSubmit?.(expandedText); await Promise.resolve(); expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); lifecycleBarrier.resolve("lifecycle-cancelled"); await submission;
expect(promptStashState.stash).toEqual({ text: rawText, expandedText, pasteSnapshot, images: [[7, image]], }); restoredSnapshot = undefined; ( InteractiveMode.prototype as unknown as { restorePromptStashIfEditorEmpty(this: unknown): boolean } ).restorePromptStashIfEditorEmpty.call(fakeThis); expect(customText).toBe(supportsSnapshotRestore ? rawText : expandedText); expect(restoredSnapshot).toBe(supportsSnapshotRestore ? pasteSnapshot : undefined); expect(promptStashState.stash).toBeUndefined(); }, );
test("captures exact rich state for Alt+Enter before clearing", async () => { const image = { type: "image", data: "base64", mimeType: "image/png" }; const pasteSnapshot = { pastes: [[3, "expanded paste"]] as const, pasteCounter: 4 }; let editorText = "alt [paste #3] [image #9]"; const fakeThis = createSubmitHandlerHarness({ editor: { getText: () => editorText, getExpandedText: () => "alt expanded paste [image #9]", getPasteSnapshot: () => pasteSnapshot, setText: (text: string) => { editorText = text; }, }, pastedImages: new Map([[9, image]]), getPromptStashImages: () => [[9, image]], admitPendingStartupPrompts: vi.fn(async (): Promise<"lifecycle-cancelled"> => "lifecycle-cancelled"), }); delete fakeThis.promptStash; fakeThis.editor.onSubmit = fakeThis.defaultEditor.onSubmit; fakeThis.snapshotPromptStash = ( InteractiveMode.prototype as unknown as { snapshotPromptStash(text: string): unknown } ).snapshotPromptStash.bind({ editor: fakeThis.editor, snapshotPromptStashFrom: ( InteractiveMode.prototype as unknown as { snapshotPromptStashFrom(editor: unknown, text: string): unknown; } ).snapshotPromptStashFrom, getPromptStashImages: fakeThis.getPromptStashImages, } as never);
await ( InteractiveMode.prototype as unknown as { handleFollowUp(this: SubmitHandlerHarness): Promise<void> } ).handleFollowUp.call(fakeThis);
expect(editorText).toBe(""); expect(fakeThis.promptStashState.stash).toEqual({ text: "alt [paste #3] [image #9]", expandedText: "alt expanded paste [image #9]", pasteSnapshot, images: [[9, image]], }); });
test("routes ! shortcuts to executeBash on the agent connection", async () => { const fakeThis = createSubmitHandlerHarness();
await fakeThis.defaultEditor.onSubmit?.("!pwd");
expect(fakeThis.agentConnection.executeBash).toHaveBeenCalledWith("pwd", { excludeFromContext: false }); expect(fakeThis.editor.addToHistory).toHaveBeenCalledWith("!pwd"); expect(fakeThis.editor.setText).toHaveBeenCalledWith(""); expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); });
test("routes !! shortcuts to executeBash with excludeFromContext", async () => { const fakeThis = createSubmitHandlerHarness();
await fakeThis.defaultEditor.onSubmit?.("!!git status");
expect(fakeThis.agentConnection.executeBash).toHaveBeenCalledWith("git status", { excludeFromContext: true }); expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); });
test("ignores bare ! and !! input instead of prompting the agent", async () => { const fakeThis = createSubmitHandlerHarness();
await fakeThis.defaultEditor.onSubmit?.("!"); await fakeThis.defaultEditor.onSubmit?.("!!");
expect(fakeThis.agentConnection.executeBash).not.toHaveBeenCalled(); expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); });
test("warns instead of executing when a bash command is already running", async () => { const fakeThis = createSubmitHandlerHarness({ isBashRunning: () => true });
await fakeThis.defaultEditor.onSubmit?.("!pwd");
expect(fakeThis.showWarning).toHaveBeenCalledWith(expect.stringContaining("A bash command is already running")); expect(fakeThis.agentConnection.executeBash).not.toHaveBeenCalled(); expect(fakeThis.editor.setText).not.toHaveBeenCalled(); });
test("surfaces executeBash transport failures via showError", async () => { const fakeThis = createSubmitHandlerHarness({ agentConnection: { prompt: vi.fn(async () => {}), executeBash: vi.fn(async () => { throw new Error("the daemon is running an older build; restart the daemon and try again"); }), getState: vi.fn(async () => ({ isBashRunning: false })), }, });
await fakeThis.defaultEditor.onSubmit?.("!pwd");
expect(fakeThis.showError).toHaveBeenCalledWith(expect.stringContaining("older build")); expect(fakeThis.patchConnectionState).toHaveBeenLastCalledWith({ isBashRunning: false }); });
test("re-syncs the bash flag from connection state when executeBash is rejected", async () => { const fakeThis = createSubmitHandlerHarness({ agentConnection: { prompt: vi.fn(async () => {}), executeBash: vi.fn(async () => { throw new Error("A bash command is already running"); }), // Another attached client holds the bash slot getState: vi.fn(async () => ({ isBashRunning: true })), }, });
await fakeThis.defaultEditor.onSubmit?.("!pwd");
expect(fakeThis.patchConnectionState).toHaveBeenLastCalledWith({ isBashRunning: true }); });});
describe("InteractiveMode MCP command", () => { type McpCommandHarness = { modelRegistry: { authStorage: { get(providerId: string): unknown } }; settingsManager: SettingsManager; uiServices: { refreshMcpProviders?(): void }; showConfigurationMenu(tab: "mcp-connections"): Promise<void>; showStatus(message: string): void; showError(message: string): void; showWarning(message: string): void; handleReloadCommand(): Promise<boolean>; reloadAfterMcpChange(message: string, successMessage?: string): Promise<void>; isAgentStreaming(): boolean; isAgentCompacting(): boolean; handleMcpCommand(args: string | undefined): Promise<void>; }; const handleMcpCommand = (InteractiveMode.prototype as unknown as McpCommandHarness).handleMcpCommand;
test("opens bare /mcp on the MCP Connections tab", async () => { const fakeThis = { showConfigurationMenu: vi.fn(async () => {}), } as unknown as McpCommandHarness;
await handleMcpCommand.call(fakeThis, undefined);
expect(fakeThis.showConfigurationMenu).toHaveBeenCalledWith("mcp-connections"); });
test("preserves the explicit /mcp list status output", async () => { const fakeThis = { modelRegistry: { authStorage: { get: vi.fn(() => undefined) } }, settingsManager: SettingsManager.inMemory({}), showConfigurationMenu: vi.fn(async () => {}), showStatus: vi.fn(), showError: vi.fn(), } as unknown as McpCommandHarness;
await handleMcpCommand.call(fakeThis, "list");
expect(fakeThis.showConfigurationMenu).not.toHaveBeenCalled(); expect(fakeThis.showStatus).toHaveBeenCalledWith(expect.stringContaining("Built-in MCP integrations:")); });
function createRenderedMcpHarness(manager: SettingsManager) { const chatContainer = new Container(); const ui = { requestRender: vi.fn() }; const showStatus = (InteractiveMode.prototype as unknown as { showStatus(message: string): void }).showStatus; return { modelRegistry: { authStorage: { get: vi.fn(() => undefined), removeVerified: vi.fn() } }, settingsManager: manager, uiServices: { refreshMcpProviders: vi.fn() }, chatContainer, ui, showStatus, showError: InteractiveMode.prototype.showError, showWarning: InteractiveMode.prototype.showWarning, reloadAfterMcpChange: ( InteractiveMode.prototype as unknown as { reloadAfterMcpChange(message: string, successMessage?: string): Promise<void>; } ).reloadAfterMcpChange, isAgentStreaming: vi.fn(() => false), isAgentCompacting: vi.fn(() => false), } as unknown as McpCommandHarness & { chatContainer: Container }; }
test("renders safe add and remove confirmations after resource reload", async () => { const manager = SettingsManager.inMemory({}); const fakeThis = createRenderedMcpHarness(manager); fakeThis.handleReloadCommand = vi.fn(async () => { fakeThis.chatContainer.clear(); return true; });
await handleMcpCommand.call(fakeThis, "add fetch -- uvx mcp-server-fetch --token secret");
expect(fakeThis.uiServices.refreshMcpProviders).toHaveBeenCalledOnce(); expect(normalizeRenderedOutput(fakeThis.chatContainer)).toContain( 'Added MCP server "fetch" (stdio). Available next turn through mcp.', ); expect(normalizeRenderedOutput(fakeThis.chatContainer)).not.toContain("uvx"); expect(normalizeRenderedOutput(fakeThis.chatContainer)).not.toContain("secret");
await handleMcpCommand.call(fakeThis, "remove fetch");
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toContain( 'Removed MCP server "fetch" (stdio). It is no longer available through mcp.', ); expect(manager.getGlobalMcpServers()).toEqual({}); });
test("keeps persisted mutation honest when MCP reload fails", async () => { const manager = SettingsManager.inMemory({}); const fakeThis = createRenderedMcpHarness(manager); fakeThis.handleReloadCommand = vi.fn(async () => { fakeThis.chatContainer.clear(); fakeThis.showError("Reload failed: broken resource"); return false; });
await handleMcpCommand.call(fakeThis, "add fetch -- uvx mcp-server-fetch");
const rendered = normalizeRenderedOutput(fakeThis.chatContainer); expect(rendered).toContain("Reload failed: broken resource"); expect(rendered).toContain("change remains saved"); expect(rendered).toContain("not active in this session"); expect(rendered).not.toContain("Available next turn through mcp"); expect(manager.getGlobalMcpServers()).toHaveProperty("fetch"); });
test("refreshes MCP providers before deferring a changed command while busy", async () => { const manager = SettingsManager.inMemory({}); const events: string[] = []; const fakeThis = createRenderedMcpHarness(manager); fakeThis.uiServices.refreshMcpProviders = vi.fn(() => events.push("refresh")); fakeThis.handleReloadCommand = vi.fn(async () => true); fakeThis.isAgentStreaming = vi.fn(() => true); fakeThis.showStatus = vi.fn((message: string) => events.push(message));
await handleMcpCommand.call(fakeThis, "add remote --url https://example.test/mcp --oauth");
expect(events[0]).toBe("refresh"); expect(fakeThis.handleReloadCommand).not.toHaveBeenCalled(); expect(events.join("\n")).toContain("Run /mcp login remote to connect."); expect(events.join("\n")).toContain("Run /reload after the current turn to activate it."); });
test("guides OAuth server additions to explicit login after refresh", async () => { const manager = SettingsManager.inMemory({}); const fakeThis = createRenderedMcpHarness(manager); const events: string[] = []; fakeThis.uiServices.refreshMcpProviders = vi.fn(() => events.push("refresh")); fakeThis.handleReloadCommand = vi.fn(async () => { events.push("reload"); return true; });
await handleMcpCommand.call(fakeThis, "add remote --url https://example.test/mcp --oauth");
expect(events).toEqual(["refresh", "reload"]); expect(normalizeRenderedOutput(fakeThis.chatContainer)).toContain("Run /mcp login remote to connect."); });
test("keeps OAuth guidance accurate for legacy UI service hosts", async () => { const manager = SettingsManager.inMemory({}); const fakeThis = createRenderedMcpHarness(manager); fakeThis.uiServices = {}; fakeThis.handleReloadCommand = vi.fn(async () => true);
await handleMcpCommand.call(fakeThis, "add remote --url https://example.test/mcp --oauth");
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toContain( "Restart Prime Agent, then run /mcp login remote to connect.", ); });
test("renders inspectable redacted list and get output", async () => { const manager = SettingsManager.inMemory({ mcpServers: { fetch: { type: "stdio", command: "secret-command", args: ["secret-argument"], env: { TOKEN: { env: "SECRET_TOKEN" } }, }, }, }); const fakeThis = createRenderedMcpHarness(manager);
await handleMcpCommand.call(fakeThis, "list"); const listOutput = normalizeRenderedOutput(fakeThis.chatContainer); expect(listOutput).toContain("User-configured MCP servers:"); expect(listOutput).toContain("fetch: stdio");
fakeThis.chatContainer.clear(); await handleMcpCommand.call(fakeThis, "get fetch"); const getOutput = normalizeRenderedOutput(fakeThis.chatContainer); expect(getOutput).toContain("fetch: stdio"); for (const output of [listOutput, getOutput]) { expect(output).not.toContain("secret-command"); expect(output).not.toContain("secret-argument"); expect(output).not.toContain("SECRET_TOKEN"); } });});
describe("InteractiveMode pending bash components", () => { beforeAll(() => { initTheme("dark"); });
const bashComponent = () => ({ render: () => [], invalidate: () => {} });
test("keeps pending bash components visible across queue display updates", () => { const pendingMessagesContainer = new Container(); const component = bashComponent(); const fakeThis = { pendingMessagesContainer, queuedMessagesContainer: new Container(), pendingBashComponents: [component], getAllQueuedMessages: () => ({ steering: [], followUp: [] }), featureHintSuppressedByQueue: false, } as unknown as InteractiveMode;
( InteractiveMode.prototype as unknown as { updatePendingMessagesDisplay(this: unknown): void } ).updatePendingMessagesDisplay.call(fakeThis);
expect(pendingMessagesContainer.children).toContain(component); });
test("does not double-prefix labeled injected queue previews", () => { const queuedMessagesContainer = new Container(); const fakeThis = { pendingMessagesContainer: new Container(), queuedMessagesContainer, pendingBashComponents: [], getAllQueuedMessages: () => ({ steering: ["Heartbeat prompt: check steering", "Goal context: steer goal", "plain steering"], followUp: [ "Heartbeat prompt: check status", "Goal context: continue goal", "plain follow-up", "/compact focus", "/unknown", ], }), isRecognizedSlashCommand: (name: string) => name === "compact", getAppKeyDisplay: () => "Ctrl+Q", featureHintSuppressedByQueue: false, clearFeatureHintPresentation: vi.fn(), } as unknown as InteractiveMode;
( InteractiveMode.prototype as unknown as { updatePendingMessagesDisplay(this: unknown): void } ).updatePendingMessagesDisplay.call(fakeThis);
const raw = queuedMessagesContainer.render(100).join("\n"); const rendered = normalizeRenderedOutput(queuedMessagesContainer); expect(raw).toContain(theme.fg("accent", "/compact")); expect(raw).not.toContain(theme.fg("accent", "/unknown")); expect(rendered).toContain("Heartbeat prompt: check steering"); expect(rendered).not.toContain("Steering: Heartbeat prompt"); expect(rendered).toContain("Goal context: steer goal"); expect(rendered).not.toContain("Steering: Goal context"); expect(rendered).toContain("Steering: plain steering"); expect(rendered).toContain("Heartbeat prompt: check status"); expect(rendered).not.toContain("Follow-up: Heartbeat prompt"); expect(rendered).toContain("Goal context: continue goal"); expect(rendered).not.toContain("Follow-up: Goal context"); expect(rendered).toContain("Follow-up: plain follow-up"); });
test("flushes pending bash components from the pending area to chat", () => { const pendingMessagesContainer = new Container(); const chatContainer = new Container(); const component = bashComponent(); pendingMessagesContainer.addChild(component); const fakeThis = { pendingMessagesContainer, chatContainer, pendingBashComponents: [component], } as unknown as InteractiveMode;
( InteractiveMode.prototype as unknown as { flushPendingBashComponents(this: unknown): void } ).flushPendingBashComponents.call(fakeThis);
expect(pendingMessagesContainer.children).not.toContain(component); expect(chatContainer.children).toContain(component); expect((fakeThis as unknown as { pendingBashComponents: unknown[] }).pendingBashComponents).toHaveLength(0); });
test("handles session events in emission order even when a handler suspends", async () => { type ConnectionListener = (event: { type: string; event: { type: string } }) => Promise<void>; let listener: ConnectionListener | undefined; const order: string[] = []; const fakeThis = { agentConnection: { subscribe: (l: ConnectionListener) => { listener = l; return () => {}; }, }, sessionEventQueue: Promise.resolve(), showError: vi.fn(), handleEvent: async (event: { type: string }) => { order.push(`start:${event.type}`); if (event.type === "bash_start") { await new Promise((resolve) => setTimeout(resolve, 10)); } order.push(`end:${event.type}`); }, } as unknown as InteractiveMode;
(InteractiveMode.prototype as unknown as { subscribeToAgent(this: unknown): void }).subscribeToAgent.call( fakeThis, );
const first = listener?.({ type: "session_event", event: { type: "bash_start" } }); const second = listener?.({ type: "session_event", event: { type: "bash_end" } }); await Promise.all([first, second]);
expect(order).toEqual(["start:bash_start", "end:bash_start", "start:bash_end", "end:bash_end"]); });
test("stops an orphaned bash loader when session render state resets", () => { const tuiStub = { terminal: { columns: 120, rows: 24 }, requestRender: () => {}, } as unknown as ConstructorParameters<typeof BashExecutionComponent>[1]; const component = new BashExecutionComponent("sleep 99", tuiStub); const loader = (component as unknown as { loader: { intervalId: unknown } }).loader; expect(loader.intervalId).not.toBeNull();
const editorStub = { clearHistory: vi.fn(), setText: vi.fn() }; const endFeatureHintRun = vi.fn(); const queueSelection = new QueueSelection(); queueSelection.move({ steering: ["s1"], followUp: [] }, "draft", -1); const fakeThis = { endFeatureHintRun, queueSelection, chatContainer: new Container(), shortcutGuideContainer: new Container(), pendingMessagesContainer: new Container(), queuedMessagesContainer: new Container(), pastedImages: new Map(), liveImageMarkerIds: () => new Set(), defaultEditor: editorStub, editor: editorStub, streamingComponent: undefined, streamingMessage: undefined, activeBashComponent: component, pendingBashComponents: [component], activityTracker: { reset: vi.fn() }, agentRunFileChanges: new Map(), discardRefineLoader: vi.fn(), recapContainer: new Container(), renderRecap: vi.fn(), ipythonToolComponents: new Map(), lateIpythonSentAgentMessages: new Map(), resetPendingToolState: vi.fn(), resetSubagentSummary: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), getGoalState: () => emptyGoalState(), } as unknown as InteractiveMode;
( InteractiveMode.prototype as unknown as { resetCurrentSessionRenderState(this: unknown): void } ).resetCurrentSessionRenderState.call(fakeThis);
expect(loader.intervalId).toBeNull(); expect(endFeatureHintRun).toHaveBeenCalledOnce(); expect((fakeThis as unknown as { activeBashComponent: unknown }).activeBashComponent).toBeUndefined(); // Queue browsing is session-scoped: Enter in the next session must be a // fresh prompt, and the previous session's stashed draft is discarded. expect(queueSelection.isBrowsing).toBe(false); expect(queueSelection.reset()).toBe(""); });});
describe("InteractiveMode connection events", () => { test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), renderInitialMessages: vi.fn(async () => {}), updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), };
await ( InteractiveMode.prototype as unknown as { renderCurrentSessionState(this: typeof harness): Promise<void>; } ).renderCurrentSessionState.call(harness); expect(harness.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(harness.syncWorkingLoader).toHaveBeenCalledOnce(); });
test("degrades heartbeat refresh failures while updating the pending display during rebind", async () => { const rebindCurrentSession = ( InteractiveMode.prototype as unknown as { rebindCurrentSession(this: InteractiveMode): Promise<void> } ).rebindCurrentSession; const updatePendingMessagesDisplay = vi.fn(); const subscribeToAgent = vi.fn(); const subscribeToRosterBar = vi.fn(async () => {}); const getState = vi.fn(async () => createConnectionState()); const harness = { unsubscribe: undefined, localSessionHost: undefined, toolDefinitionCache: { clear: vi.fn() }, applyRuntimeSettings: vi.fn(), bindLocalSessionExtensions: true, bindCurrentSessionExtensions: vi.fn(async () => {}), subscribeToAgent, subscribeToRosterBar, agentConnection: { getState }, patchConnectionState: vi.fn(), refreshQueueSelectionFromState: vi.fn(), updatePendingMessagesDisplay, refreshHeartbeatCatalog: vi.fn(async () => { throw new Error("heartbeat unavailable"); }), updateAvailableProviderCount: vi.fn(async () => {}), updateEditorBorderColor: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), syncWorkingLoader: vi.fn(), getGoalState: () => emptyGoalState(), } as unknown as InteractiveMode;
await expect(rebindCurrentSession.call(harness)).resolves.toBeUndefined(); expect(updatePendingMessagesDisplay).toHaveBeenCalledOnce(); // The queue re-sync must run after subscribing, or updates in the gap are lost. expect(getState.mock.invocationCallOrder[0]).toBeGreaterThan( subscribeToAgent.mock.invocationCallOrder[0] as number, ); });
test("restores in-flight assistant state on every session render", async () => { const streamingMessage = { role: "assistant", content: [{ type: "text", text: "still streaming" }], } as AgentConnectionSnapshot["streamingMessage"]; const snapshots: AgentConnectionSnapshot[] = [ { state: createConnectionState(), messages: [] }, { state: createConnectionState({ isStreaming: true }), messages: [], streamingMessage }, ]; const renderSessionContextMock = vi.fn(async () => {}); const restoreStreamingMessageFromSnapshot = vi.fn(async () => {}); const fakeThis = { agentConnection: { getInitialSnapshot: vi.fn(async () => snapshots.shift()!) }, getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [], thinkingLevel: "medium", model: null, })), seedSubagentSummary: vi.fn(), applyConnectionStateSnapshot: vi.fn(), renderSessionContext: renderSessionContextMock, restoreStreamingMessageFromSnapshot, restoreTurnStartFromMessages: vi.fn(), showStatus: vi.fn(), } as unknown as InteractiveMode;
const renderInitialMessages = ( InteractiveMode.prototype as unknown as { renderInitialMessages(this: InteractiveMode): Promise<void> } ).renderInitialMessages; await renderInitialMessages.call(fakeThis); await renderInitialMessages.call(fakeThis);
expect( (fakeThis as unknown as { agentConnection: { getInitialSnapshot: ReturnType<typeof vi.fn> } }).agentConnection .getInitialSnapshot, ).toHaveBeenCalledTimes(2); expect(renderSessionContextMock).toHaveBeenCalledTimes(2); expect(renderSessionContextMock).toHaveBeenNthCalledWith(1, expect.anything(), { updateFooter: true, populateHistory: true, limitTranscript: true, }); expect(renderSessionContextMock).toHaveBeenNthCalledWith(2, expect.anything(), { updateFooter: true, populateHistory: true, limitTranscript: true, }); expect(restoreStreamingMessageFromSnapshot).toHaveBeenNthCalledWith(1, undefined); expect(restoreStreamingMessageFromSnapshot).toHaveBeenNthCalledWith(2, streamingMessage); });
test("clears extension UI when a connection-backed session is replaced", async () => { type SessionReplacedEvent = { type: "session_replaced"; state: AgentConnectionState; messages: [] }; let listener: ((event: SessionReplacedEvent) => Promise<void> | void) | undefined; const fakeThis = { agentConnection: { subscribe: vi.fn((callback) => { listener = callback; return vi.fn(); }), }, sessionEventQueue: Promise.resolve(), sessionEventGeneration: 0, resetSideQuestion: vi.fn(), resetExtensionUI: vi.fn(), applyConnectionStateSnapshot: vi.fn(), resetCurrentSessionRenderState: vi.fn(), rebindCurrentSession: vi.fn(async () => {}), renderInitialMessages: vi.fn(async () => {}), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), handleConnectionExtensionUiRequest: vi.fn(), showError: vi.fn(), };
(InteractiveMode.prototype as unknown as { subscribeToAgent(this: typeof fakeThis): void }).subscribeToAgent.call( fakeThis, );
const state = createConnectionState(); await listener?.({ type: "session_replaced", state, messages: [] });
const resetOrder = fakeThis.resetExtensionUI.mock.invocationCallOrder[0]; const applySnapshotOrder = fakeThis.applyConnectionStateSnapshot.mock.invocationCallOrder[0]; const resetRenderOrder = fakeThis.resetCurrentSessionRenderState.mock.invocationCallOrder[0]; const rebindOrder = fakeThis.rebindCurrentSession.mock.invocationCallOrder[0]; const renderMessagesOrder = fakeThis.renderInitialMessages.mock.invocationCallOrder[0]; expect(resetOrder).toBeLessThan(applySnapshotOrder); expect(applySnapshotOrder).toBeLessThan(resetRenderOrder); expect(resetRenderOrder).toBeLessThan(rebindOrder); expect(rebindOrder).toBeLessThan(renderMessagesOrder); expect(fakeThis.applyConnectionStateSnapshot).toHaveBeenCalledWith(state); expect(fakeThis.resetCurrentSessionRenderState).toHaveBeenCalledWith(); expect(fakeThis.renderInitialMessages).toHaveBeenCalledWith(); expect(fakeThis.ui.requestRender).toHaveBeenCalledWith(); });
test("resynchronizes transcript state without destructive session teardown", async () => { type SessionResyncedEvent = { type: "session_resynced"; snapshot: { state: AgentConnectionState; messages: [] }; }; let listener: ((event: SessionResyncedEvent) => Promise<void> | void) | undefined; const fakeThis = { agentConnection: { subscribe: vi.fn((callback) => { listener = callback; return vi.fn(); }), }, sessionEventQueue: Promise.resolve(), sessionEventGeneration: 0, renderResyncedSession: vi.fn(async () => {}), refreshCommandCatalogForCurrentSession: vi.fn(async () => {}), resetSideQuestion: vi.fn(), resetExtensionUI: vi.fn(), resetCurrentSessionRenderState: vi.fn(), rebindCurrentSession: vi.fn(), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), handleConnectionExtensionUiRequest: vi.fn(), showError: vi.fn(), };
(InteractiveMode.prototype as unknown as { subscribeToAgent(this: typeof fakeThis): void }).subscribeToAgent.call( fakeThis, );
const snapshot = { state: createConnectionState(), messages: [] as [] }; await listener?.({ type: "session_resynced", snapshot });
expect(fakeThis.refreshCommandCatalogForCurrentSession).toHaveBeenCalledOnce(); expect(fakeThis.renderResyncedSession).toHaveBeenCalledWith(snapshot); expect(fakeThis.refreshCommandCatalogForCurrentSession.mock.invocationCallOrder[0]).toBeLessThan( fakeThis.renderResyncedSession.mock.invocationCallOrder[0]!, ); expect(fakeThis.resetSideQuestion).not.toHaveBeenCalled(); expect(fakeThis.resetExtensionUI).not.toHaveBeenCalled(); expect(fakeThis.resetCurrentSessionRenderState).not.toHaveBeenCalled(); expect(fakeThis.rebindCurrentSession).not.toHaveBeenCalled(); });
test("drops a resync superseded while its command catalog refreshes", async () => { type ConnectionEvent = | { type: "session_resynced"; snapshot: { state: AgentConnectionState; messages: [] } } | { type: "session_replaced"; state: AgentConnectionState; messages: [] }; let listener: ((event: ConnectionEvent) => Promise<void> | void) | undefined; let releaseCatalog = () => {}; const catalog = new Promise<void>((resolve) => { releaseCatalog = resolve; }); const fakeThis = { agentConnection: { subscribe: vi.fn((callback) => { listener = callback; return vi.fn(); }), }, sessionEventQueue: Promise.resolve(), sessionEventGeneration: 0, refreshCommandCatalogForCurrentSession: vi.fn(() => catalog), renderResyncedSession: vi.fn(async () => {}), resetSideQuestion: vi.fn(), resetExtensionUI: vi.fn(), applyConnectionStateSnapshot: vi.fn(), resetCurrentSessionRenderState: vi.fn(), rebindCurrentSession: vi.fn(async () => {}), renderInitialMessages: vi.fn(async () => {}), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), handleConnectionExtensionUiRequest: vi.fn(), showError: vi.fn(), }; (InteractiveMode.prototype as unknown as { subscribeToAgent(this: typeof fakeThis): void }).subscribeToAgent.call( fakeThis, );
const resync = listener?.({ type: "session_resynced", snapshot: { state: createConnectionState(), messages: [] }, }); await vi.waitFor(() => expect(fakeThis.refreshCommandCatalogForCurrentSession).toHaveBeenCalledOnce()); const replacement = listener?.({ type: "session_replaced", state: createConnectionState(), messages: [] }); releaseCatalog(); await Promise.all([resync, replacement]);
expect(fakeThis.renderResyncedSession).not.toHaveBeenCalled(); expect(fakeThis.renderInitialMessages).toHaveBeenCalledOnce(); });
test("exits stale queue browsing when a resync replaces the queue snapshot", async () => { const queueSelection = new QueueSelection(); let editorText = "draft"; queueSelection.move({ steering: [], followUp: ["queued"] }, editorText, -1); editorText = "queued"; const snapshot: AgentConnectionSnapshot = { state: createConnectionState({ sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }), messages: [], }; const fakeThis = { connectionState: createConnectionState({ sessionActions: { queuedCount: 1, steering: [], followUps: ["queued"] }, }), queueSelection, pendingQueueEdit: undefined, pendingQueueMove: false, isApplyingQueueSelectionText: false, editor: { getText: () => editorText, setText: (text: string) => { editorText = text; }, }, isBashRunning: () => false, applyConnectionStateSnapshot: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [], thinkingLevel: "medium", model: null, })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(), updatePendingMessagesDisplay: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), syncWorkingLoader: vi.fn(), getGoalState: () => emptyGoalState(), }; fakeThis.applyConnectionStateSnapshot.mockImplementation((state: AgentConnectionState) => { fakeThis.connectionState = state; }); Object.setPrototypeOf(fakeThis, InteractiveMode.prototype);
await ( InteractiveMode.prototype as unknown as { renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise<void>; } ).renderResyncedSession.call(fakeThis, snapshot);
expect(queueSelection.isBrowsing).toBe(false); expect(editorText).toBe("draft"); });
test("preserves client-local work while rendering a resynchronized snapshot", async () => { const sideQuestion = { id: "side-1", status: "running" }; const extensionRequests = new Map([["request-1", { cancelLocal: vi.fn() }]]); const activeBashComponent = {}; const streamingMessage = { role: "assistant", content: [{ type: "thinking", thinking: "Still reasoning" }], } as AgentConnectionSnapshot["streamingMessage"]; const snapshot: AgentConnectionSnapshot = { state: createConnectionState({ isCompacting: true, isBashRunning: true, isStreaming: true }), messages: [userMessage("Still working", 100)], streamingMessage, }; const startAssistantStreamingMessage = vi.fn(); const restoreStreamingMessageFromSnapshot = vi.fn((message: AgentConnectionSnapshot["streamingMessage"]) => { if (message?.role === "assistant") { startAssistantStreamingMessage(message); } }); const refreshCommandCatalogForCurrentSession = vi.fn(async () => {}); const fakeThis = { turnStartedAt: 1, workingStartedAt: 1, sideQuestionEvent: sideQuestion, activeConnectionExtensionUiRequests: extensionRequests, activeBashComponent, refreshCommandCatalogForCurrentSession, isAgentCompacting: () => true, isBashRunning: () => true, isAgentStreaming: () => true, streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: snapshot.messages, thinkingLevel: "medium", model: null, })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot, updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), syncWorkingLoader: vi.fn(), getGoalState: () => emptyGoalState(), } as unknown as InteractiveMode; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype);
await ( InteractiveMode.prototype as unknown as { renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise<void>; } ).renderResyncedSession.call(fakeThis, snapshot);
expect((fakeThis as unknown as { sideQuestionEvent: unknown }).sideQuestionEvent).toBe(sideQuestion); expect( (fakeThis as unknown as { activeConnectionExtensionUiRequests: unknown }).activeConnectionExtensionUiRequests, ).toBe(extensionRequests); expect((fakeThis as unknown as { activeBashComponent: unknown }).activeBashComponent).toBe(activeBashComponent); expect( (fakeThis as unknown as { renderSessionContext: ReturnType<typeof vi.fn> }).renderSessionContext, ).toHaveBeenCalledWith(expect.anything(), { clearChat: true, updateFooter: true }); expect(startAssistantStreamingMessage).toHaveBeenCalledWith(streamingMessage); expect((fakeThis as unknown as { turnStartedAt: number | undefined }).turnStartedAt).toBe(100); expect((fakeThis as unknown as { workingStartedAt: number | undefined }).workingStartedAt).toBe(100); expect( (fakeThis as unknown as { updateWorkingLoaderMessage: ReturnType<typeof vi.fn> }).updateWorkingLoaderMessage, ).toHaveBeenCalledOnce(); expect(refreshCommandCatalogForCurrentSession).not.toHaveBeenCalled(); });
test("finishes local bash UI when a resync proves the operation ended", async () => { const bashComponent = { setComplete: vi.fn() }; const flushPendingBashComponents = vi.fn(); const snapshot: AgentConnectionSnapshot = { state: createConnectionState({ isCompacting: false, isBashRunning: false, isStreaming: false }), messages: [], }; const fakeThis = { activeBashComponent: bashComponent, streamingComponent: {}, streamingMessage: {}, isAgentCompacting: () => true, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), refreshQueueSelectionFromState: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [], thinkingLevel: "medium", model: null, })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(), updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents, updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), syncWorkingLoader: vi.fn(), getGoalState: () => emptyGoalState(), } as unknown as InteractiveMode;
await ( InteractiveMode.prototype as unknown as { renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise<void>; } ).renderResyncedSession.call(fakeThis, snapshot);
expect(bashComponent.setComplete).toHaveBeenCalledWith(undefined, false); expect((fakeThis as unknown as { activeBashComponent: unknown }).activeBashComponent).toBeUndefined(); expect(flushPendingBashComponents).toHaveBeenCalledOnce(); });
test("routes /resume to the resume command handler", async () => { const fakeThis = createSubmitHandlerHarness();
await fakeThis.defaultEditor.onSubmit?.("/resume abc123");
expect(fakeThis.handleResumeCommand).toHaveBeenCalledWith("abc123"); expect(fakeThis.showError).not.toHaveBeenCalled(); expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); });
test("renderCurrentSessionState waits for replacement handling before rendering", async () => { const calls: string[] = []; const fakeThis = { sessionEventQueue: Promise.resolve().then(() => { calls.push("replacement"); }), resetCurrentSessionRenderState: () => calls.push("reset"), renderInitialMessages: async () => calls.push("messages"), updatePendingMessagesDisplay: () => calls.push("display"), syncWorkingLoader: () => calls.push("loader"), };
await ( InteractiveMode.prototype as unknown as { renderCurrentSessionState(this: typeof fakeThis): Promise<void>; } ).renderCurrentSessionState.call(fakeThis);
expect(calls).toEqual(["replacement", "reset", "messages", "display", "loader"]); });
test("drops a queued source event after the session is replaced", async () => { type Event = | { type: "session_event"; event: { type: string } } | { type: "session_replaced"; state: AgentConnectionState }; let listener: ((event: Event) => Promise<void>) | undefined; let releaseQueue: (() => void) | undefined; const blocked = new Promise<void>((resolve) => { releaseQueue = resolve; }); const fakeThis = { agentConnection: { subscribe: (callback: (event: Event) => Promise<void>) => { listener = callback; return vi.fn(); }, }, sessionEventQueue: blocked, sessionEventGeneration: 0, handleEvent: vi.fn(async () => {}), resetSideQuestion: vi.fn(), resetExtensionUI: vi.fn(), applyConnectionStateSnapshot: vi.fn(), resetCurrentSessionRenderState: vi.fn(), rebindCurrentSession: vi.fn(async () => {}), renderInitialMessages: vi.fn(async () => {}), ui: { requestRender: vi.fn() }, showError: vi.fn(), }; (InteractiveMode.prototype as unknown as { subscribeToAgent(this: typeof fakeThis): void }).subscribeToAgent.call( fakeThis, );
const staleEvent = listener?.({ type: "session_event", event: { type: "message_update" } }); const replacement = listener?.({ type: "session_replaced", state: createConnectionState() }); releaseQueue?.(); await Promise.all([staleEvent, replacement]);
expect(fakeThis.handleEvent).not.toHaveBeenCalled(); expect(fakeThis.renderInitialMessages).toHaveBeenCalledOnce(); });});
describe("InteractiveMode connection extension UI", () => { type ActiveConnectionExtensionUiRequest = { cancelLocal(): void };
type ConnectionExtensionUiCancelHarness = { activeConnectionExtensionUiRequests: Map<string, ActiveConnectionExtensionUiRequest>; agentConnection: { respondToExtensionUiRequest(requestId: string, response: AgentConnectionExtensionUiResponse): Promise<void>; }; showError(message: string): void; cancelActiveConnectionExtensionUiRequests(): void; };
type ConnectionExtensionUiHandlerHarness = ConnectionExtensionUiCancelHarness & { resolveConnectionExtensionUiRequest( request: AgentConnectionExtensionUiRequest, ): Promise<AgentConnectionExtensionUiResponse | undefined>; handleConnectionExtensionUiRequest(request: AgentConnectionExtensionUiRequest): Promise<void>; };
const prototype = InteractiveMode.prototype as unknown as ConnectionExtensionUiHandlerHarness;
test("reset cancellation responds to active connection UI requests", async () => { const cancelLocal = vi.fn(); const fakeThis = Object.create(InteractiveMode.prototype) as ConnectionExtensionUiCancelHarness; fakeThis.activeConnectionExtensionUiRequests = new Map([["request-1", { cancelLocal }]]); fakeThis.agentConnection = { respondToExtensionUiRequest: vi.fn(async () => {}), }; fakeThis.showError = vi.fn();
prototype.cancelActiveConnectionExtensionUiRequests.call(fakeThis); await Promise.resolve();
expect(fakeThis.activeConnectionExtensionUiRequests.size).toBe(0); expect(cancelLocal).toHaveBeenCalledTimes(1); expect(fakeThis.agentConnection.respondToExtensionUiRequest).toHaveBeenCalledWith("request-1", { cancelled: true, }); expect(fakeThis.showError).not.toHaveBeenCalled(); });
test("connection UI handler does not double respond after reset cancellation", async () => { const fakeThis = Object.create(InteractiveMode.prototype) as ConnectionExtensionUiHandlerHarness; fakeThis.activeConnectionExtensionUiRequests = new Map(); fakeThis.agentConnection = { respondToExtensionUiRequest: vi.fn(async () => {}), }; fakeThis.showError = vi.fn(); fakeThis.resolveConnectionExtensionUiRequest = vi.fn( () => new Promise<AgentConnectionExtensionUiResponse | undefined>(() => { // Intentionally left pending until cancellation wins the race. }), );
const request: AgentConnectionExtensionUiRequest = { id: "request-1", method: "select", payload: {}, }; const handling = prototype.handleConnectionExtensionUiRequest.call(fakeThis, request); expect(fakeThis.activeConnectionExtensionUiRequests.size).toBe(1);
prototype.cancelActiveConnectionExtensionUiRequests.call(fakeThis); await handling;
expect(fakeThis.agentConnection.respondToExtensionUiRequest).toHaveBeenCalledTimes(1); expect(fakeThis.agentConnection.respondToExtensionUiRequest).toHaveBeenCalledWith("request-1", { cancelled: true, }); expect(fakeThis.activeConnectionExtensionUiRequests.size).toBe(0); });});
describe("InteractiveMode key handlers", () => { type KeyHandlerHarness = { defaultEditor: { onEscape?: () => void; onCtrlD?: () => void; onMoveBelowPrompt?: () => void; onChange?: () => void; onPasteImage?: () => void; onAction(action: string, handler: () => void): void; }; ui: { onDebug?: () => void; requestRender: ReturnType<typeof vi.fn> }; handleEscape(): void; setupKeyHandlers(): void; };
test("routes Escape through the repeat handler", () => { const fakeThis = Object.create(InteractiveMode.prototype) as KeyHandlerHarness; fakeThis.defaultEditor = { onAction: vi.fn(), }; fakeThis.ui = { requestRender: vi.fn() }; fakeThis.handleEscape = vi.fn();
fakeThis.setupKeyHandlers(); fakeThis.defaultEditor.onEscape?.();
expect(fakeThis.handleEscape).toHaveBeenCalledTimes(1); });});
describe("InteractiveMode tool event rendering", () => { test("reserves streaming tool call ids before loading tool definitions", async () => { let resolveDefinition!: () => void; const definitionPromise = new Promise<undefined>((resolve) => { resolveDefinition = () => resolve(undefined); }); const fakeThis = Object.assign(Object.create(InteractiveMode.prototype), { isInitialized: true, init: vi.fn(async () => {}), footer: { invalidate: vi.fn() }, updateConnectionStateFromEvent: vi.fn(), activityTracker: new AgentActivityTracker(), streamingComponent: { updateContent: vi.fn() }, streamingMessage: undefined, chatContainer: new Container(), pendingTools: new Map<string, ToolExecutionComponent>(), pendingToolCreations: new Set<string>(), startedToolCalls: new Set<string>(), ipythonToolComponents: new Map(), lateIpythonSentAgentMessages: new Map(), loadToolDefinition: vi.fn(() => definitionPromise), uiServices: { settingsManager: { getShowImages: () => true, }, }, toolOutputExpanded: false, ui: { requestRender: vi.fn() }, getCurrentCwd: () => "/tmp/project", }); const message = { role: "assistant", content: [ { type: "toolCall", id: "tool-1", name: "ipython", arguments: { code: "print(1)" }, }, ], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, }, }, stopReason: "stop", timestamp: 1, }; const event = { type: "message_update", message, assistantMessageEvent: { type: "toolcall_start", contentIndex: 0, partial: message }, } as unknown as AgentConnectionSessionEvent; const handleEvent = ( InteractiveMode.prototype as unknown as { handleEvent(this: typeof fakeThis, event: AgentConnectionSessionEvent): Promise<void>; } ).handleEvent;
const firstUpdate = handleEvent.call(fakeThis, event); const secondUpdate = handleEvent.call(fakeThis, event); await Promise.resolve(); expect(fakeThis.loadToolDefinition).toHaveBeenCalledTimes(1);
resolveDefinition(); await Promise.all([firstUpdate, secondUpdate]);
expect(fakeThis.pendingTools.size).toBe(1); expect(fakeThis.chatContainer.children).toHaveLength(1); });});
describe("InteractiveMode transcript rebuild", () => { type RebuildHarness = { chatContainer: Container; agentConnection: { getSessionContext(): Promise<AgentConnectionSessionContext> }; renderSessionContext(context: AgentConnectionSessionContext, options: RenderSessionContextOptions): Promise<void>; rebuildChatFromMessages(): Promise<void>; };
function createRebuildHarness(): RebuildHarness { const fakeThis = Object.create(InteractiveMode.prototype) as RebuildHarness; fakeThis.chatContainer = new Container(); fakeThis.chatContainer.addChild(new Container()); return fakeThis; }
test("keeps existing chat visible when session context reload fails", async () => { const fakeThis = createRebuildHarness(); fakeThis.agentConnection = { getSessionContext: vi.fn(async () => { throw new Error("context unavailable"); }), }; fakeThis.renderSessionContext = vi.fn(async () => {});
await expect(fakeThis.rebuildChatFromMessages()).rejects.toThrow("context unavailable");
expect(fakeThis.chatContainer.children).toHaveLength(1); expect(fakeThis.renderSessionContext).not.toHaveBeenCalled(); });
test("replaces existing chat after session context reload succeeds", async () => { const fakeThis = createRebuildHarness(); const staleChild = fakeThis.chatContainer.children[0]; const rebuiltChild = new Container(); const context: AgentConnectionSessionContext = { messages: [], thinkingLevel: "medium", serviceTier: "default", model: null, }; fakeThis.agentConnection = { getSessionContext: vi.fn(async () => context) }; fakeThis.renderSessionContext = vi.fn(async (_context, options) => { if (options.clearChat) fakeThis.chatContainer.clear(); fakeThis.chatContainer.addChild(rebuiltChild); });
await fakeThis.rebuildChatFromMessages();
expect(fakeThis.renderSessionContext).toHaveBeenCalledWith(context, { clearChat: true }); expect(fakeThis.chatContainer.children).toEqual([rebuiltChild]); expect(fakeThis.chatContainer.children).not.toContain(staleChild); });});
describe("InteractiveMode startup onboarding warnings", () => { type StartupWarningHarness = { getCurrentModel(): AgentConnectionModel | undefined; getModelFallbackWarningAction(modelFallbackMessage: string | undefined): "show" | "suppress"; };
const getModelFallbackWarningAction = (InteractiveMode.prototype as unknown as StartupWarningHarness) .getModelFallbackWarningAction;
const createHarness = (options: { currentModel?: AgentConnectionModel }): StartupWarningHarness => ({ getCurrentModel: vi.fn(() => options.currentModel), getModelFallbackWarningAction, });
const liveModel = { id: "gpt-5.5", provider: "prime-inference" } as AgentConnectionModel;
test("suppresses the no-model warning when the live session has a model", () => { const fakeThis = createHarness({ currentModel: liveModel });
expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage())).toBe("suppress"); });
test("shows the no-model warning when the live session has no model", () => { const fakeThis = createHarness({});
expect(getModelFallbackWarningAction.call(fakeThis, formatNoModelsAvailableMessage())).toBe("show"); });
test("keeps real model restore fallback warnings after onboarding", () => { const fakeThis = createHarness({ currentModel: liveModel });
expect( getModelFallbackWarningAction.call( fakeThis, "Could not restore model anthropic/claude-old. Using prime-inference/openai/gpt-5.5.", ), ).toBe("show"); });});
describe("InteractiveMode model candidates", () => { type ModelCandidatesHarness = { agentConnection: { getModelCatalog: () => Promise<AgentConnectionModelCatalog> }; connectionModelCatalog: AgentConnectionModel[]; connectionConfiguredProviders: Set<string>; connectionModelsFetchedAt: number; connectionModelsRefreshVersion: number; connectionModelsRefreshInFlight: { version: number; promise: Promise<AgentConnectionModel[]> } | undefined; getScopedModelState(): AgentConnectionState["scopedModels"]; applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void; getAvailableConnectionModels(): AgentConnectionModel[]; getConnectionAvailableModels(): Promise<AgentConnectionModel[]>; getModelCandidates(): Promise<AgentConnectionModel[]>; getScopedModelsFromModelIds( enabledIds: readonly string[], allModels: readonly AgentConnectionModel[], ): AgentConnectionState["scopedModels"]; }; const prototype = InteractiveMode.prototype as unknown as ModelCandidatesHarness;
const createModel = (provider: string, id: string): AgentConnectionModel => ({ provider, id, name: id, }) as AgentConnectionModel;
test("loads unscoped model candidates through AgentConnection", async () => { const model = createModel("openai", "gpt-5.5"); const getModelCatalog = vi.fn(async () => ({ models: [model], configuredProviders: [model.provider] })); const fakeThis: ModelCandidatesHarness = { agentConnection: { getModelCatalog }, connectionModelCatalog: [], connectionConfiguredProviders: new Set(), connectionModelsFetchedAt: 0, connectionModelsRefreshVersion: 0, connectionModelsRefreshInFlight: undefined, getScopedModelState: () => [], applyConnectionModelCatalog: prototype.applyConnectionModelCatalog, getAvailableConnectionModels: prototype.getAvailableConnectionModels, getConnectionAvailableModels: prototype.getConnectionAvailableModels, getModelCandidates: prototype.getModelCandidates, getScopedModelsFromModelIds: prototype.getScopedModelsFromModelIds, };
const result = await prototype.getModelCandidates.call(fakeThis);
expect(result).toEqual([model]); expect(getModelCatalog).toHaveBeenCalledTimes(1); expect(fakeThis.getAvailableConnectionModels()).toEqual([model]); });
test("uses connection state for scoped model candidates", async () => { const model = createModel("anthropic", "claude-opus-4-5"); const getModelCatalog = vi.fn(async () => { const available = createModel("openai", "gpt-5.5"); return { models: [available], configuredProviders: [available.provider] }; }); const fakeThis: ModelCandidatesHarness = { agentConnection: { getModelCatalog }, connectionModelCatalog: [], connectionConfiguredProviders: new Set(), connectionModelsFetchedAt: 0, connectionModelsRefreshVersion: 0, connectionModelsRefreshInFlight: undefined, getScopedModelState: () => [{ model, thinkingLevel: "medium" }], applyConnectionModelCatalog: prototype.applyConnectionModelCatalog, getAvailableConnectionModels: prototype.getAvailableConnectionModels, getConnectionAvailableModels: prototype.getConnectionAvailableModels, getModelCandidates: prototype.getModelCandidates, getScopedModelsFromModelIds: prototype.getScopedModelsFromModelIds, };
const result = await prototype.getModelCandidates.call(fakeThis);
expect(result).toEqual([model]); expect(getModelCatalog).not.toHaveBeenCalled(); });
test("maps selected scoped model IDs from connection candidates", () => { const anthropicModel = createModel("anthropic", "claude-opus-4-5"); const openaiModel = createModel("openai", "gpt-5.5");
const result = prototype.getScopedModelsFromModelIds( ["missing/model", "anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5", "openai/gpt-5.5"], [openaiModel, anthropicModel], );
expect(result).toEqual([{ model: anthropicModel }, { model: openaiModel }]); });});
describe("InteractiveMode model selection persistence", () => { type ModelSelectionHarness = { agentConnection: { setModel(provider: string, modelId: string): Promise<void>; getState(): Promise<AgentConnectionState>; }; uiServices: { settingsManager: { setDefaultModelAndProvider(provider: string, modelId: string): void }; }; footer: { invalidate(): void }; subagentSummaryLine: { invalidate(): void }; patchConnectionState(patch: Partial<AgentConnectionState>): void; updateEditorBorderColor(): void; showStatus(message: string): void; showError(message: string): void; connectionConfiguredProviders: Set<string>; createAuthFlows(): { getLoginProviderOptions(): ReadonlyArray<AuthSelectorProvider>; loginProvider(provider: AuthSelectorProvider): Promise<AuthenticationResult>; }; ensureModelProviderConfigured( model: AgentConnectionModel, authFlows: ReturnType<ModelSelectionHarness["createAuthFlows"]>, providerOptions: ReadonlyArray<AuthSelectorProvider>, ): Promise<boolean>; completeModelSelection(model: AgentConnectionModel): Promise<void>; findExactModelMatch(searchTerm: string): Promise<AgentConnectionModel | undefined>; maybeWarnAboutAnthropicSubscriptionAuth(model: AgentConnectionModel): Promise<void>; checkDaxnutsEasterEgg(model: AgentConnectionModel): void; applySelectedModel(model: AgentConnectionModel): Promise<void>; handleModelCommand(searchTerm?: string): Promise<void>; setupAutocompleteProvider(): void; }; type ModelSelectorOverlayHarness = { agentConnection: { getModelCatalog(): Promise<AgentConnectionModelCatalog>; setModel(provider: string, modelId: string): Promise<void>; }; connectionModelCatalog: AgentConnectionModel[]; connectionConfiguredProviders: Set<string>; connectionModelsFetchedAt: number; connectionModelsRefreshVersion: number; connectionModelsRefreshInFlight: { version: number; promise: Promise<AgentConnectionModel[]> } | undefined; ui: TUI; uiServices: { modelRegistry: ModelRegistry; settingsManager: { getRecentModels(): string[]; setDefaultModelAndProvider(provider: string, modelId: string): void; }; }; footer: { invalidate(): void }; patchConnectionState(patch: Partial<AgentConnectionState>): void; updateEditorBorderColor(): void; showStatus(message: string): void; showError(message: string): void; getScopedModelState(): AgentConnectionState["scopedModels"]; getCurrentModel(): AgentConnectionModel | undefined; applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void; getAvailableConnectionModels(): AgentConnectionModel[]; findExactModelMatch(searchTerm: string): Promise<AgentConnectionModel | undefined>; getConnectionAvailableModels(): Promise<AgentConnectionModel[]>; getCachedModelCandidates(): AgentConnectionModel[]; getModelSelectorRefreshPromise(options?: { force?: boolean }): Promise<AgentConnectionModel[]> | undefined; createAuthFlows(): { getLoginProviderOptions(): ReadonlyArray<AuthSelectorProvider>; loginProvider(provider: AuthSelectorProvider): Promise<AuthenticationResult>; }; ensureModelProviderConfigured( model: AgentConnectionModel, authFlows: ReturnType<ModelSelectorOverlayHarness["createAuthFlows"]>, providerOptions: ReadonlyArray<AuthSelectorProvider>, ): Promise<boolean>; completeModelSelection(model: AgentConnectionModel): Promise<void>; applySelectedModel(model: AgentConnectionModel): Promise<void>; showFullPaneOverlay( component: Component, maxContentWidth?: number, ): { hide(): void; setHidden(hidden: boolean): void; focus(): void }; showConfigurationMenu( tab: "providers" | "models" | "mcp-connections", initialSearchInput?: string, ): Promise<void>; maybeWarnAboutAnthropicSubscriptionAuth(model: AgentConnectionModel): Promise<void>; checkDaxnutsEasterEgg(model: AgentConnectionModel): void; setupAutocompleteProvider(): void; };
const createModel = (provider: string, id: string): AgentConnectionModel => ({ provider, id, name: id, }) as AgentConnectionModel; const overlayPrototype = InteractiveMode.prototype as unknown as ModelSelectorOverlayHarness;
function createSelectorOverlayHarness(options: { connectionModels: AgentConnectionModel[]; catalogModels?: AgentConnectionModel[]; configuredProviders?: ReadonlyArray<string>; registryModels?: AgentConnectionModel[]; getAvailableModels?: () => Promise<AgentConnectionModel[]>; getModelCatalog?: () => Promise<AgentConnectionModelCatalog>; providerOptions?: AuthSelectorProvider[]; loginProvider?: (provider: AuthSelectorProvider) => Promise<AuthenticationResult>; applySelectedModel?: (model: AgentConnectionModel) => Promise<void>; currentModel?: AgentConnectionModel; connectionModelsFetchedAt?: number; scopedModels?: AgentConnectionState["scopedModels"]; hasConfiguredAuth?: (model: AgentConnectionModel) => boolean; }) { let overlayComponent: Component | undefined; const hide = vi.fn(); const setHidden = vi.fn(); const focus = vi.fn(); const registryModels = [...(options.registryModels ?? options.connectionModels)]; const catalogModels = [...(options.catalogModels ?? options.connectionModels)]; const configuredProviders = new Set( options.configuredProviders ?? options.connectionModels.map((model) => model.provider), ); const modelRegistry = { authStorage: AuthStorage.inMemory(), refresh: vi.fn(), getError: vi.fn(() => undefined), getAvailable: vi.fn(() => registryModels), hasConfiguredAuth: vi.fn((model: AgentConnectionModel) => options.hasConfiguredAuth?.(model) ?? false), getProviderAuthStatus: vi.fn(() => ({ configured: false })), find: vi.fn((provider: string, modelId: string) => registryModels.find((model) => model.provider === provider && model.id === modelId), ), } as unknown as ModelRegistry; const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectorOverlayHarness; fakeThis.agentConnection = { getModelCatalog: options.getModelCatalog ?? vi.fn(async () => { const models = options.getAvailableModels ? await options.getAvailableModels() : options.connectionModels; return { models: options.catalogModels ?? models, configuredProviders: options.configuredProviders ? [...options.configuredProviders] : models.map((model) => model.provider), }; }), setModel: vi.fn(async () => {}), }; fakeThis.connectionModelCatalog = catalogModels; fakeThis.connectionConfiguredProviders = configuredProviders; fakeThis.connectionModelsFetchedAt = options.connectionModelsFetchedAt ?? 0; fakeThis.connectionModelsRefreshVersion = 0; fakeThis.connectionModelsRefreshInFlight = undefined; fakeThis.ui = { requestRender: vi.fn(), terminal: { rows: 24 }, } as unknown as TUI; fakeThis.uiServices = { modelRegistry, settingsManager: { getRecentModels: vi.fn(() => []), setDefaultModelAndProvider: vi.fn(), }, }; fakeThis.footer = { invalidate: vi.fn() }; fakeThis.patchConnectionState = vi.fn(); fakeThis.updateEditorBorderColor = vi.fn(); fakeThis.showStatus = vi.fn(); fakeThis.showError = vi.fn(); fakeThis.getScopedModelState = vi.fn(() => options.scopedModels ?? []); fakeThis.getCurrentModel = vi.fn(() => options.currentModel); fakeThis.applyConnectionModelCatalog = overlayPrototype.applyConnectionModelCatalog; fakeThis.findExactModelMatch = ( InteractiveMode.prototype as unknown as { findExactModelMatch(searchTerm: string): Promise<AgentConnectionModel | undefined>; } ).findExactModelMatch; fakeThis.getConnectionAvailableModels = overlayPrototype.getConnectionAvailableModels; fakeThis.getCachedModelCandidates = overlayPrototype.getCachedModelCandidates; fakeThis.getModelSelectorRefreshPromise = overlayPrototype.getModelSelectorRefreshPromise; fakeThis.createAuthFlows = vi.fn(() => ({ getLoginProviderOptions: () => options.providerOptions ?? [], loginProvider: options.loginProvider ?? (async () => ({ status: "cancelled" as const })), })); fakeThis.applySelectedModel = options.applySelectedModel ?? vi.fn(async () => {}); fakeThis.ensureModelProviderConfigured = overlayPrototype.ensureModelProviderConfigured; fakeThis.completeModelSelection = overlayPrototype.completeModelSelection; fakeThis.showFullPaneOverlay = vi.fn((component: Component) => { overlayComponent = component; return { hide, setHidden, focus }; }); fakeThis.showConfigurationMenu = overlayPrototype.showConfigurationMenu; fakeThis.maybeWarnAboutAnthropicSubscriptionAuth = vi.fn(async () => {}); fakeThis.checkDaxnutsEasterEgg = vi.fn(); fakeThis.setupAutocompleteProvider = vi.fn();
return { fakeThis, hide, setHidden, focus, getSelector: () => { if (!overlayComponent) { throw new Error("Expected model selector overlay to be shown"); } return overlayComponent as ConfigurationMenuComponent; }, }; }
beforeAll(() => { initTheme("dark"); });
test("persists local default only after the connection accepts the model", async () => { const order: string[] = []; const model = createModel("openai", "gpt-5.5"); const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectionHarness; fakeThis.agentConnection = { setModel: vi.fn(async () => { order.push("connection"); }), getState: vi.fn(async () => createConnectionState({ model, serviceTier: "default", availableThinkingLevels: ["off"] }), ), }; fakeThis.uiServices = { settingsManager: { setDefaultModelAndProvider: vi.fn(() => { order.push("settings"); }), }, }; fakeThis.footer = { invalidate: vi.fn() }; fakeThis.subagentSummaryLine = { invalidate: vi.fn() }; fakeThis.patchConnectionState = vi.fn(); fakeThis.updateEditorBorderColor = vi.fn(); fakeThis.setupAutocompleteProvider = vi.fn();
await fakeThis.applySelectedModel(model);
expect(fakeThis.agentConnection.setModel).toHaveBeenCalledWith("openai", "gpt-5.5"); expect(fakeThis.uiServices.settingsManager.setDefaultModelAndProvider).toHaveBeenCalledWith("openai", "gpt-5.5"); expect(order).toEqual(["connection", "settings"]); expect(fakeThis.patchConnectionState).toHaveBeenCalledWith({ model, serviceTier: "default", availableThinkingLevels: ["off"], }); expect(fakeThis.footer.invalidate).toHaveBeenCalledTimes(1); expect(fakeThis.updateEditorBorderColor).toHaveBeenCalledTimes(1); });
test("does not persist local default when the connection rejects the model", async () => { const model = createModel("openai", "missing-model"); const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectionHarness; fakeThis.agentConnection = { setModel: vi.fn(async () => { throw new Error("model unavailable"); }), getState: vi.fn(async () => createConnectionState()), }; fakeThis.uiServices = { settingsManager: { setDefaultModelAndProvider: vi.fn(), }, }; fakeThis.footer = { invalidate: vi.fn() }; fakeThis.subagentSummaryLine = { invalidate: vi.fn() }; fakeThis.patchConnectionState = vi.fn(); fakeThis.updateEditorBorderColor = vi.fn();
await expect(fakeThis.applySelectedModel(model)).rejects.toThrow("model unavailable");
expect(fakeThis.uiServices.settingsManager.setDefaultModelAndProvider).not.toHaveBeenCalled(); expect(fakeThis.patchConnectionState).not.toHaveBeenCalled(); expect(fakeThis.footer.invalidate).not.toHaveBeenCalled(); expect(fakeThis.updateEditorBorderColor).not.toHaveBeenCalled(); });
test("persists exact /model command selections after the connection accepts them", async () => { const model = createModel("openai", "gpt-5.5"); const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectionHarness; fakeThis.agentConnection = { setModel: vi.fn(async () => {}), getState: vi.fn(async () => createConnectionState({ model, serviceTier: "default", availableThinkingLevels: ["off"] }), ), }; fakeThis.uiServices = { settingsManager: { setDefaultModelAndProvider: vi.fn(), }, }; fakeThis.footer = { invalidate: vi.fn() }; fakeThis.subagentSummaryLine = { invalidate: vi.fn() }; fakeThis.patchConnectionState = vi.fn(); fakeThis.updateEditorBorderColor = vi.fn(); fakeThis.showStatus = vi.fn(); fakeThis.showError = vi.fn(); fakeThis.connectionConfiguredProviders = new Set([model.provider]); fakeThis.createAuthFlows = vi.fn(() => ({ getLoginProviderOptions: () => [], loginProvider: async () => ({ status: "cancelled" as const }), })); fakeThis.ensureModelProviderConfigured = overlayPrototype.ensureModelProviderConfigured; fakeThis.completeModelSelection = overlayPrototype.completeModelSelection; fakeThis.findExactModelMatch = vi.fn(async () => model); fakeThis.maybeWarnAboutAnthropicSubscriptionAuth = vi.fn(async () => {}); fakeThis.checkDaxnutsEasterEgg = vi.fn(); fakeThis.setupAutocompleteProvider = vi.fn();
await fakeThis.handleModelCommand("gpt-5.5");
expect(fakeThis.agentConnection.setModel).toHaveBeenCalledWith("openai", "gpt-5.5"); expect(fakeThis.uiServices.settingsManager.setDefaultModelAndProvider).toHaveBeenCalledWith("openai", "gpt-5.5"); expect(fakeThis.patchConnectionState).toHaveBeenCalledWith({ model, serviceTier: "default", availableThinkingLevels: ["off"], }); expect(fakeThis.showStatus).toHaveBeenCalledWith("Model: gpt-5.5"); expect(fakeThis.showError).not.toHaveBeenCalled(); });
test("opens the model selector before live model refresh resolves", async () => { const cachedModel = createModel("openai", "gpt-5.5"); const liveModels = createDeferred<AgentConnectionModel[]>(); const getAvailableModels = vi.fn(() => liveModels.promise); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [cachedModel], getAvailableModels, });
const result = fakeThis.showConfigurationMenu("models");
expect(fakeThis.showFullPaneOverlay).toHaveBeenCalledTimes(1); expect(getAvailableModels).toHaveBeenCalledTimes(1);
getSelector().handleInput("\x1b"); liveModels.resolve([cachedModel]);
await expect(result).resolves.toBeUndefined(); });
test("updates the model selector from an already in-flight refresh", async () => { const alpha = createModel("openai", "alpha"); const beta = { ...createModel("openai", "beta"), name: "Beta Model" }; const liveModels = createDeferred<AgentConnectionModel[]>(); const getAvailableModels = vi.fn(async () => [alpha]); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [alpha], connectionModelsFetchedAt: Date.now(), getAvailableModels, }); fakeThis.connectionModelsRefreshInFlight = { version: 0, promise: liveModels.promise.then((models) => { fakeThis.applyConnectionModelCatalog({ models, configuredProviders: models.map((model) => model.provider), }); return models; }), };
const result = fakeThis.showConfigurationMenu("models", "beta"); await flushAsyncWork();
expect(getAvailableModels).not.toHaveBeenCalled(); expect(getSelector().render(120).join("\n")).not.toContain("Beta");
liveModels.resolve([beta]); await flushAsyncWork();
expect(getSelector().render(120).join("\n")).toContain("Beta");
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("refreshes cached candidates for exact model misses", async () => { const alpha = createModel("openai", "alpha"); const beta = createModel("openai", "beta"); const getAvailableModels = vi.fn(async () => [beta]); const { fakeThis } = createSelectorOverlayHarness({ connectionModels: [alpha], connectionModelsFetchedAt: Date.now(), getAvailableModels, });
await expect(fakeThis.findExactModelMatch("beta")).resolves.toEqual(beta);
expect(getAvailableModels).toHaveBeenCalledTimes(1); });
test("does not exact-match local fallback before daemon catalog loads", async () => { const localOnly = createModel("openai", "local-only"); const getAvailableModels = vi.fn(async () => []); const { fakeThis } = createSelectorOverlayHarness({ connectionModels: [], registryModels: [localOnly], getAvailableModels, });
await expect(fakeThis.findExactModelMatch("local-only")).resolves.toBeUndefined();
expect(getAvailableModels).toHaveBeenCalledTimes(1); });
test("keeps a fetched empty daemon catalog empty", () => { const localOnly = createModel("openai", "local-only"); const { fakeThis } = createSelectorOverlayHarness({ connectionModels: [], connectionModelsFetchedAt: Date.now(), registryModels: [localOnly], });
expect(fakeThis.getCachedModelCandidates()).toEqual([]); });
test("does not show local fallback before daemon catalog loads", async () => { const localOnly = createModel("openai", "local-only"); const liveModels = createDeferred<AgentConnectionModel[]>(); const getAvailableModels = vi.fn(() => liveModels.promise); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [], registryModels: [localOnly], getAvailableModels, });
const result = fakeThis.showConfigurationMenu("models"); await flushAsyncWork();
expect(getAvailableModels).toHaveBeenCalledTimes(1); expect(getSelector().render(120).join("\n")).not.toContain("local-only");
liveModels.resolve([]); await flushAsyncWork(); getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("keeps cached daemon models visible when scoped models are active", async () => { const previousKeybindings = getKeybindings(); setKeybindings(new KeybindingsManager()); const scopedModel = createModel("openai", "scoped"); const catalogModel = createModel("anthropic", "catalog"); const getAvailableModels = vi.fn(async () => [catalogModel]); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [catalogModel], connectionModelsFetchedAt: Date.now(), getAvailableModels, scopedModels: [{ model: scopedModel }], });
const result = fakeThis.showConfigurationMenu("models");
expect(getAvailableModels).not.toHaveBeenCalled(); expect(getSelector().render(120).join("\n")).toContain("scoped"); expect(fakeThis.getCachedModelCandidates()).toEqual([scopedModel, catalogModel]); await expect(fakeThis.findExactModelMatch("catalog")).resolves.toEqual(catalogModel);
getSelector().handleInput("\x1bs"); expect(getSelector().render(120).join("\n")).toContain("catalog"); expect(getAvailableModels).not.toHaveBeenCalled(); getSelector().handleInput("\t"); expect(getSelector().getActiveTab()).toBe("mcp-connections");
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); setKeybindings(previousKeybindings); });
test("uses a fresh cached model catalog without starting another refresh", async () => { const cachedModel = createModel("openai", "gpt-5.5"); const getAvailableModels = vi.fn(async () => [cachedModel]); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [cachedModel], connectionModelsFetchedAt: Date.now(), getAvailableModels, });
const result = fakeThis.showConfigurationMenu("models");
expect(fakeThis.showFullPaneOverlay).toHaveBeenCalledTimes(1); expect(getAvailableModels).not.toHaveBeenCalled();
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("closes the model selector before the selected model finishes applying", async () => { const model = createModel("openai", "gpt-5.5"); const apply = createDeferred<void>(); const { fakeThis, getSelector, hide, setHidden } = createSelectorOverlayHarness({ connectionModels: [model], applySelectedModel: vi.fn(() => apply.promise), });
const result = fakeThis.showConfigurationMenu("models"); await flushAsyncWork();
let resolved = false; void result.then((nextResult) => { resolved = nextResult === undefined; });
getSelector().handleInput("\r"); await flushAsyncWork();
expect(setHidden).toHaveBeenCalledWith(true); expect(hide).not.toHaveBeenCalled(); expect(fakeThis.showStatus).toHaveBeenCalledWith("Switching model: gpt-5.5"); expect(resolved).toBe(false);
apply.resolve();
await expect(result).resolves.toBeUndefined(); expect(hide).toHaveBeenCalledTimes(1); expect(fakeThis.showStatus).toHaveBeenCalledWith("Model: gpt-5.5"); });
test("reopens the model selector when the selected model fails to apply", async () => { const model = createModel("openai", "gpt-5.5"); const { fakeThis, getSelector, hide, setHidden, focus } = createSelectorOverlayHarness({ connectionModels: [model], applySelectedModel: vi.fn(async () => { throw new Error("model switch failed"); }), });
const result = fakeThis.showConfigurationMenu("models"); await flushAsyncWork(); getSelector().handleInput("\r"); await flushAsyncWork();
expect(hide).not.toHaveBeenCalled(); expect(setHidden).toHaveBeenNthCalledWith(1, true); expect(setHidden).toHaveBeenNthCalledWith(2, false); expect(focus).toHaveBeenCalled(); expect(fakeThis.showError).toHaveBeenCalledWith("model switch failed");
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); expect(hide).toHaveBeenCalledTimes(1); });
test("authenticates an unavailable model provider before applying the model", async () => { const model = createModel("openai", "gpt-5.5"); const provider: AuthSelectorProvider = { id: "openai", name: "OpenAI", authType: "api_key" }; let authenticated = false; const getModelCatalog = vi.fn(async () => ({ models: [model], configuredProviders: [], })); const loginProvider = vi.fn(async (): Promise<AuthenticationResult> => { authenticated = true; return { status: "success", providerId: provider.id, providerName: provider.name, authType: provider.authType, }; }); const applySelectedModel = vi.fn(async () => {}); const { fakeThis, getSelector, hide } = createSelectorOverlayHarness({ connectionModels: [], catalogModels: [model], configuredProviders: [], connectionModelsFetchedAt: Date.now(), getModelCatalog, providerOptions: [provider], loginProvider, applySelectedModel, hasConfiguredAuth: () => authenticated, });
const result = fakeThis.showConfigurationMenu("models"); getSelector().handleInput("\r"); await flushAsyncWork();
expect(loginProvider).toHaveBeenCalledWith(provider); expect(getModelCatalog).toHaveBeenCalledTimes(1); expect(applySelectedModel).toHaveBeenCalledWith(model); expect(hide).toHaveBeenCalledTimes(1); await expect(result).resolves.toBeUndefined(); });
test("keeps the model selector open when provider authentication is cancelled", async () => { const model = createModel("openai", "gpt-5.5"); const provider: AuthSelectorProvider = { id: "openai", name: "OpenAI", authType: "api_key" }; const loginProvider = vi.fn(async () => ({ status: "cancelled" as const })); const applySelectedModel = vi.fn(async () => {}); const { fakeThis, getSelector, hide } = createSelectorOverlayHarness({ connectionModels: [], catalogModels: [model], configuredProviders: [], connectionModelsFetchedAt: Date.now(), providerOptions: [provider], loginProvider, applySelectedModel, });
const result = fakeThis.showConfigurationMenu("models"); getSelector().handleInput("\r"); await flushAsyncWork();
expect(loginProvider).toHaveBeenCalledWith(provider); expect(applySelectedModel).not.toHaveBeenCalled(); expect(hide).not.toHaveBeenCalled();
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("refreshes model selector results in the background without clearing search", async () => { const alpha = createModel("openai", "alpha"); const beta = { ...createModel("openai", "beta"), name: "Beta Model" }; const liveModels = createDeferred<AgentConnectionModel[]>(); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [alpha], getAvailableModels: vi.fn(() => liveModels.promise), });
const result = fakeThis.showConfigurationMenu("models", "beta"); await flushAsyncWork();
const selector = getSelector(); expect(selector.getSearchValue()).toBe("beta"); expect(selector.render(120).join("\n")).not.toContain("Beta");
liveModels.resolve([beta]); await flushAsyncWork();
expect(selector.getSearchValue()).toBe("beta"); expect(selector.render(120).join("\n")).toContain("Beta");
selector.handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("refreshes fresh cached selector results when opened with a search", async () => { const alpha = createModel("openai", "alpha"); const beta = { ...createModel("openai", "beta"), name: "Beta Model" }; const getAvailableModels = vi.fn(async () => [beta]); const { fakeThis, getSelector } = createSelectorOverlayHarness({ connectionModels: [alpha], connectionModelsFetchedAt: Date.now(), getAvailableModels, });
const result = fakeThis.showConfigurationMenu("models", "beta"); await flushAsyncWork();
expect(getAvailableModels).toHaveBeenCalledTimes(1); expect(getSelector().render(120).join("\n")).toContain("Beta");
getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); });
test("does not let a stale model refresh overwrite a newer catalog refresh", async () => { const oldModel = createModel("openai", "old"); const freshModel = createModel("openai", "fresh"); const oldModels = createDeferred<AgentConnectionModelCatalog>(); const getModelCatalog = vi .fn<() => Promise<AgentConnectionModelCatalog>>() .mockImplementationOnce(() => oldModels.promise) .mockImplementationOnce(async () => ({ models: [freshModel], configuredProviders: [freshModel.provider] })); const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectorOverlayHarness & { connectionCommands: unknown[]; connectionResourceSnapshot: unknown; refreshConnectionCatalog(): Promise<void>; applyConnectionStateSnapshot(state: AgentConnectionState): void; invalidateConnectionModels(): void; invalidateConnectionModelRefresh(): void; }; fakeThis.agentConnection = { getModelCatalog, getState: vi.fn(async () => createConnectionState()), getCommands: vi.fn(async () => []), getResourceSnapshot: vi.fn(async () => ({})), setModel: vi.fn(async () => {}), } as never; fakeThis.connectionModelCatalog = []; fakeThis.connectionConfiguredProviders = new Set(); fakeThis.connectionModelsFetchedAt = 0; fakeThis.connectionModelsRefreshVersion = 0; fakeThis.connectionModelsRefreshInFlight = undefined; fakeThis.getScopedModelState = vi.fn(() => []); fakeThis.applyConnectionModelCatalog = overlayPrototype.applyConnectionModelCatalog; fakeThis.getConnectionAvailableModels = overlayPrototype.getConnectionAvailableModels; fakeThis.refreshConnectionCatalog = ( InteractiveMode.prototype as unknown as { refreshConnectionCatalog(): Promise<void> } ).refreshConnectionCatalog; fakeThis.invalidateConnectionModels = ( InteractiveMode.prototype as unknown as { invalidateConnectionModels(): void } ).invalidateConnectionModels; fakeThis.invalidateConnectionModelRefresh = ( InteractiveMode.prototype as unknown as { invalidateConnectionModelRefresh(): void } ).invalidateConnectionModelRefresh; fakeThis.applyConnectionStateSnapshot = vi.fn();
const staleRefresh = fakeThis.getConnectionAvailableModels(); await fakeThis.refreshConnectionCatalog(); oldModels.resolve({ models: [oldModel], configuredProviders: [oldModel.provider] });
await expect(staleRefresh).resolves.toEqual([freshModel]);
expect(fakeThis.getAvailableConnectionModels()).toEqual([freshModel]); });
test("keeps the cached model catalog when a catalog refresh fails", async () => { const cachedModel = createModel("openai", "cached"); const fakeThis = Object.create(InteractiveMode.prototype) as ModelSelectorOverlayHarness & { connectionCommands: unknown[]; connectionResourceSnapshot: unknown; refreshConnectionCatalog(): Promise<void>; applyConnectionStateSnapshot(state: AgentConnectionState): void; invalidateConnectionModelRefresh(): void; }; fakeThis.agentConnection = { getModelCatalog: vi.fn(async () => { const fresh = createModel("openai", "fresh"); return { models: [fresh], configuredProviders: [fresh.provider] }; }), getState: vi.fn(async () => createConnectionState()), getCommands: vi.fn(async () => { throw new Error("commands unavailable"); }), getResourceSnapshot: vi.fn(async () => ({})), setModel: vi.fn(async () => {}), } as never; fakeThis.connectionModelCatalog = [cachedModel]; fakeThis.connectionConfiguredProviders = new Set([cachedModel.provider]); fakeThis.connectionModelsFetchedAt = Date.now(); fakeThis.connectionModelsRefreshVersion = 0; fakeThis.connectionModelsRefreshInFlight = undefined; fakeThis.applyConnectionModelCatalog = overlayPrototype.applyConnectionModelCatalog; fakeThis.refreshConnectionCatalog = ( InteractiveMode.prototype as unknown as { refreshConnectionCatalog(): Promise<void> } ).refreshConnectionCatalog; fakeThis.invalidateConnectionModelRefresh = ( InteractiveMode.prototype as unknown as { invalidateConnectionModelRefresh(): void } ).invalidateConnectionModelRefresh; fakeThis.applyConnectionStateSnapshot = vi.fn();
await expect(fakeThis.refreshConnectionCatalog()).resolves.toBeUndefined();
expect(fakeThis.connectionCommands).toEqual([]); expect(fakeThis.getAvailableConnectionModels()).toEqual([expect.objectContaining({ id: "fresh" })]); expect(fakeThis.connectionModelsFetchedAt).toBeGreaterThan(0); });
test("keeps an empty configuration menu open when the background refresh fails", async () => { const getAvailableModels = vi.fn(async () => { throw new Error("models unavailable"); }); const { fakeThis, getSelector, hide } = createSelectorOverlayHarness({ connectionModels: [], getAvailableModels, });
const result = fakeThis.showConfigurationMenu("models"); await flushAsyncWork();
expect(fakeThis.showError).toHaveBeenCalledWith("models unavailable"); expect(hide).not.toHaveBeenCalled(); getSelector().handleInput("\x1b"); await expect(result).resolves.toBeUndefined(); expect(hide).toHaveBeenCalledTimes(1); });});
function createFakeConnectionSession(commandName: string): AgentSessionRuntime["session"] { return { extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [ { name: commandName, sourceInfo: { path: `/${commandName}.md`, source: `/${commandName}.md`, scope: "project", origin: "top-level", }, }, ], resourceLoader: { getSkills: () => ({ skills: [] }), getPrompts: () => ({ prompts: [] }), getThemes: () => ({ themes: [] }), getExtensions: () => ({ extensions: [], errors: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), }, modelRegistry: { refreshModelCatalog: async () => ({ models: [], configuredProviders: [] }) }, sessionManager: { getCwd: () => "/tmp/project", getSessionDir: () => "/tmp/sessions", getLeafId: () => null, getEntries: () => [], }, getAvailableThinkingLevels: () => ["medium"], getActiveToolNames: () => [], getContextUsage: () => undefined, thinkingLevel: "medium", serviceTier: "default", isStreaming: false, isCompacting: false, isBashRunning: false, retryAttempt: 0, steeringMode: "all", followUpMode: "all", sessionId: commandName, autoCompactionEnabled: true, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, getSessionActionSnapshot: () => ({ queuedCount: 0, steering: [], followUps: [] }), goalState: emptyGoalState(), scopedModels: [], subscribe: () => () => {}, messages: [], } as unknown as AgentSessionRuntime["session"];}
class EventEmittingReplacementRuntime { private rebindSession: Parameters<AgentSessionRuntime["setRebindSession"]>[0];
constructor( public session: AgentSessionRuntime["session"], private readonly replacement: AgentSessionRuntime["session"], ) {}
setRebindSession(callback?: Parameters<AgentSessionRuntime["setRebindSession"]>[0]): void { this.rebindSession = callback; }
setBeforeSessionInvalidate(): void {}
newSession(): Promise<{ cancelled: boolean }> { return this.replaceSession(); }
switchSession(): Promise<{ cancelled: boolean }> { return this.replaceSession(); }
async fork(): Promise<{ cancelled: boolean; selectedText?: string }> { return this.replaceSession(); }
private async replaceSession(): Promise<{ cancelled: boolean }> { this.session = this.replacement; await this.rebindSession?.(this.replacement); return { cancelled: false }; }}
describe("InteractiveMode session switch command catalog", () => { test.each(["switchSession", "newSession", "fork"] as const)( "refreshes the command catalog for an event-emitting in-process %s replacement exactly once before replay", async (operation) => { const sourceSession = createFakeConnectionSession("source-command"); const targetSession = createFakeConnectionSession("target-command"); const runtime = new EventEmittingReplacementRuntime(sourceSession, targetSession); const connection = new InProcessAgentConnection(runtime as unknown as AgentSessionRuntime); const getCommands = vi.spyOn(connection, "getCommands"); const calls: string[] = []; const fakeThis = { agentConnection: connection, sessionEventQueue: Promise.resolve(), sessionEventGeneration: 0, connectionCommands: await connection.getCommands(), connectionModelsRefreshVersion: 0, bindLocalSessionExtensions: false, uiServices: { getThemes: () => [] }, toolDefinitionCache: { clear: vi.fn() }, applyRuntimeSettings: vi.fn(), applyConnectionModelCatalog: vi.fn(), showLoadedResources: vi.fn(), refreshHeartbeatCatalog: vi.fn(async () => {}), updateAvailableProviderCount: vi.fn(async () => {}), updateEditorBorderColor: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), syncGoalTray: vi.fn(), getGoalState: () => emptyGoalState(), resetSideQuestion: vi.fn(), resetExtensionUI: vi.fn(), applyConnectionStateSnapshot: vi.fn(), resetCurrentSessionRenderState: vi.fn(() => calls.push("reset")), queueSelection: { selected: undefined }, setupAutocompleteProvider: vi.fn(() => calls.push("catalog")), renderInitialMessages: vi.fn(async () => { calls.push("render"); expect(fakeThis.connectionCommands.map((command) => command.name)).toEqual(["target-command"]); }), updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), handleConnectionExtensionUiRequest: vi.fn(), showError: vi.fn(), }; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); const interactiveHarness = fakeThis as typeof fakeThis & { subscribeToAgent(): void; renderCurrentSessionState(): Promise<void>; }; interactiveHarness.subscribeToAgent();
if (operation === "switchSession") await connection.switchSession("/target/session.jsonl"); else if (operation === "newSession") await connection.newSession(); else await connection.fork("entry-1"); await interactiveHarness.renderCurrentSessionState();
expect(calls).toEqual(["reset", "catalog", "render", "reset", "render"]); expect(getCommands).toHaveBeenCalledTimes(2); // initial catalog + one replacement refresh }, );
test("keeps catalog refresh failures nonfatal and drops stale dynamic commands", async () => { const setupAutocompleteProvider = vi.fn(); const fakeThis = Object.create(InteractiveMode.prototype) as { agentConnection: { getCommands(): Promise<never> }; connectionCommands: unknown[]; setupAutocompleteProvider(): void; refreshCommandCatalogForCurrentSession(): Promise<void>; }; fakeThis.agentConnection = { getCommands: vi.fn(async () => { throw new Error("catalog unavailable"); }), }; fakeThis.connectionCommands = [{ name: "stale-command" }]; fakeThis.setupAutocompleteProvider = setupAutocompleteProvider;
await expect(fakeThis.refreshCommandCatalogForCurrentSession()).resolves.toBeUndefined();
expect(fakeThis.connectionCommands).toEqual([]); expect(setupAutocompleteProvider).toHaveBeenCalledOnce(); });});
describe("InteractiveMode Prime CLI onboarding", () => { type OnboardingSplashHandle = { showProgress(message: string): void; dismiss(): void; }; type OnboardingHarness = { shouldRunOnboarding(): boolean; markOnboardingShown(): void; runStartupOnboarding(): Promise<boolean>; runOnboardingFlow(showPrimeCliSplash?: boolean): Promise<void>; applySelectedModel(model: AgentConnectionModel): Promise<void>; prepareForModelSelectionAfterLogin(authResult: AuthenticationResult): Promise<boolean>; setupAutocompleteProvider(): void; }; type OnboardingFake = OnboardingHarness & { connectionState: AgentConnectionState; agentConnection: { getAvailableModels?: () => Promise<AgentConnectionModel[]>; setModel?: (provider: string, modelId: string) => Promise<void>; }; uiServices: { modelRegistry: { authStorage: Pick<AuthStorage, "get">; refresh: () => void; hasConfiguredAuth: (model: unknown) => boolean; getProviderAuthStatus: (provider: string) => AuthStatus; }; settingsManager: { getOnboardingShown: () => boolean; setOnboardingShown: (shown: boolean) => void; setDefaultModelAndProvider: (provider: string, modelId: string) => void; flush: () => Promise<void>; }; }; footer?: { invalidate: () => void }; updateEditorBorderColor?: () => void; showStatus?: (message: string) => void; showError?: (message: string) => void; patchConnectionState?: (patch: Partial<AgentConnectionState>) => void; maybeWarnAboutAnthropicSubscriptionAuth?: (model?: AgentConnectionModel) => void; checkDaxnutsEasterEgg?: (model: { provider: string; id: string }) => void; findExactModelMatch?: (searchTerm: string) => Promise<AgentConnectionModel | undefined>; showOnboardingSplash?: (continueActionLabel?: string) => Promise<OnboardingSplashHandle | undefined>; createAuthFlows?: () => { runPrimeInferenceLogin(): Promise<AuthenticationResult> }; showConfigurationMenu?: (tab: "providers" | "models" | "mcp-connections") => Promise<void>; getModelCandidates?: () => Promise<AgentConnectionModel[]>; }; const shouldRunOnboarding = (InteractiveMode.prototype as unknown as OnboardingHarness).shouldRunOnboarding; const markOnboardingShown = (InteractiveMode.prototype as unknown as OnboardingHarness).markOnboardingShown; const runStartupOnboarding = (InteractiveMode.prototype as unknown as OnboardingHarness).runStartupOnboarding; const runOnboardingFlow = (InteractiveMode.prototype as unknown as OnboardingHarness).runOnboardingFlow; const startupRunResult = { type: "agents_view", source: { activeSessionId: undefined, sessionFile: undefined, sessionId: "", sessionName: undefined, cwd: "/tmp/project", }, }; function createStartupRunHarness( options: Record<string, unknown>, overrides: Record<string, unknown> = {}, ): Record<string, unknown> & { getUserInput: ReturnType<typeof vi.fn> } { return { init: vi.fn(async () => {}), restorePromptStashOnOpen: vi.fn(), options: { agentsViewOwnsStartupNotices: true, ...options }, modelRegistry: { getError: vi.fn(() => undefined) }, runStartupOnboarding: vi.fn(async () => true), getModelFallbackWarningAction: vi.fn(() => "suppress"), maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(), getUserInput: vi.fn(() => new Promise<void>(() => {})), showWarning: vi.fn(), showError: vi.fn(), getCurrentCwd: () => startupRunResult.source.cwd, connectionState: { messageCount: 0 }, ...overrides, }; }
const primeModel: AgentConnectionModel = { id: "openai/gpt-5.5", name: "GPT-5.5", api: "openai-completions", provider: PRIME_INFERENCE_PROVIDER_ID, baseUrl: "https://api.pinference.ai/api/v1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1050000, maxTokens: 128000, } as AgentConnectionModel;
test("defers, retries transient startup failures, and skips a persistently failing prompt", async () => { vi.useFakeTimers(); const inputDone = createDeferred<void>(); let model: AgentConnectionModel | undefined; let firstAttempts = 0; let secondAttempts = 0; const prompt = vi.fn((message: string) => { if (message === "first" && firstAttempts++ < 2) { return Promise.reject(new Error(`transient failure ${firstAttempts}`)); } if (message === "second") { return Promise.reject(new Error(`admission failure ${++secondAttempts}`)); } return Promise.resolve(); }); const showError = vi.fn(); const submitHarness = createSubmitHandlerHarness({ agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, }); const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessage: "first", initialMessages: ["second", "third"] }, { getCurrentModel: () => model, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, showError, }, ), );
try { const run = InteractiveMode.prototype.run.call(fakeThis as never); await vi.advanceTimersByTimeAsync(250); expect(prompt).not.toHaveBeenCalled();
model = primeModel; const userSubmission = fakeThis.defaultEditor.onSubmit?.("user"); await vi.advanceTimersByTimeAsync(1_250); await userSubmission;
const steer = { images: undefined, streamingBehavior: "steer", queueIfBusy: true, signal: expect.any(AbortSignal), }; const followUp = { images: undefined, streamingBehavior: "followUp", queueIfBusy: true, signal: expect.any(AbortSignal), }; expect(prompt.mock.calls).toEqual([ ["first", steer], ["first", steer], ["first", steer], ["second", followUp], ["second", followUp], ["second", followUp], ["third", followUp], ["user", { images: [], streamingBehavior: "steer", queueIfBusy: true }], ]); expect(showError.mock.calls).toEqual([ ["transient failure 1"], ["transient failure 2"], ["admission failure 1"], ["admission failure 2"], ["Skipping startup prompt after 3 failed attempts: admission failure 3"], ]);
// Delivery settled: the retry cadence stops instead of re-prompting. await vi.advanceTimersByTimeAsync(1_000); expect(prompt).toHaveBeenCalledTimes(8); inputDone.resolve(undefined); await expect(run).resolves.toEqual(startupRunResult); } finally { vi.useRealTimers(); } });
test.each(["unsupported", "unknown"] as const)( "reports %s startup admission once and retains every remaining prompt with collision-safe images", async (status) => { const inputDone = createDeferred<void>(); const firstImage = { type: "image", data: "first", mimeType: "image/png" } as const; const secondImage = { type: "image", data: "second", mimeType: "image/jpeg" } as const; let rejectStartup!: (error: Error) => void; const startupAdmission = new Promise<void>((_resolve, reject) => { rejectStartup = reject; }); const prompt = vi.fn(() => startupAdmission); const promptStashState: PromptStashState = { stash: { text: "existing draft [image #3]" } }; const submitHarness = createSubmitHandlerHarness({ promptStashState, agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, pastedImages: new Map([[1, { type: "image", data: "existing", mimeType: "image/png" }]]), nextImageMarkerId: 1, }); const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessage: "startup [image #1]", initialImages: [firstImage], initialPrompts: [{ text: "later [image #2]", images: [secondImage] }, { text: "last [image #4]" }], }, { getCurrentModel: () => primeModel, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, }, ), );
const run = InteractiveMode.prototype.run.call(fakeThis as never); await vi.waitFor(() => expect(prompt).toHaveBeenCalledOnce()); const blockedSubmission = fakeThis.defaultEditor.onSubmit?.("blocked user"); rejectStartup(new AgentConnectionPromptAdmissionError("daemon admission uncertain", status)); await blockedSubmission; // An erroneous retry would already be scheduled; drain continuations. for (let i = 0; i < 25; i++) await new Promise((resolve) => setImmediate(resolve)); expect(prompt).toHaveBeenCalledOnce(); expect(fakeThis.showError).toHaveBeenCalledWith("daemon admission uncertain"); expect(fakeThis.promptStashState.stash).toEqual({ text: "startup [image #5] [image #6]", images: [[6, firstImage]], }); expect(fakeThis.promptStashState.queuedStashes).toEqual([ { text: "later [image #2] [image #7]", images: [[7, secondImage]] }, { text: "last [image #4]" }, { text: "existing draft [image #3]" }, { text: "blocked user" }, ]); expect(fakeThis.pastedImages.get(1)).toEqual({ type: "image", data: "existing", mimeType: "image/png" }); expect(fakeThis.pastedImages.get(5)).toBeUndefined(); expect(fakeThis.pastedImages.get(6)).toBe(firstImage); expect(fakeThis.pastedImages.get(7)).toBe(secondImage); inputDone.resolve(undefined); await expect(run).resolves.toMatchObject({ type: "agents_view" }); }, );
test("does not retain a startup prompt already owned by the session", async () => { const inputDone = createDeferred<void>(); const prompt = vi .fn() .mockRejectedValueOnce(new AgentConnectionPromptAdmissionError("session owns prompt", "owned")) .mockResolvedValueOnce(undefined); const submitHarness = createSubmitHandlerHarness({ agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, }); const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessages: ["owned startup", "next startup"] }, { getCurrentModel: () => primeModel, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, }, ), );
const run = InteractiveMode.prototype.run.call(fakeThis as never); await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(2)); expect(prompt.mock.calls.map(([message]) => message)).toEqual(["owned startup", "next startup"]); expect(fakeThis.promptStashState.stash).toBeUndefined(); inputDone.resolve(undefined); await expect(run).resolves.toMatchObject({ type: "agents_view" }); });
test.each(["typing", "Alt+Enter"] as const)( "preserves %s during the startup admission barrier", async (scenario) => { vi.useFakeTimers(); let model: AgentConnectionModel | undefined; let editorText = "first user prompt"; let startupAttempts = 0; const startupAdmission = createDeferred<void>(); const inputDone = createDeferred<void>(); const prompt = vi.fn((message: string) => { if (message !== "startup") return Promise.resolve(); startupAttempts++; return startupAttempts === 1 ? Promise.reject(new Error("transient admission failure")) : startupAdmission.promise; }); const submitHarness = createSubmitHandlerHarness({ editor: { getText: () => editorText, getExpandedText: () => editorText, setText: (text) => { editorText = text; }, }, agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, }); const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessage: "startup" }, { getCurrentModel: () => model, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, }, ), );
try { const run = InteractiveMode.prototype.run.call(fakeThis as never); await vi.advanceTimersByTimeAsync(250); fakeThis.editor.onSubmit = fakeThis.defaultEditor.onSubmit; const submit = () => scenario === "typing" ? fakeThis.defaultEditor.onSubmit?.(editorText) : ( InteractiveMode.prototype as unknown as { handleFollowUp(this: SubmitHandlerHarness): Promise<void>; } ).handleFollowUp.call(fakeThis); const firstSubmission = submit(); await Promise.resolve(); expect(editorText).toBe(""); if (scenario === "typing") editorText = "typing during startup wait"; const duplicate = scenario === "Alt+Enter" ? submit() : undefined; expect(prompt).not.toHaveBeenCalled(); model = primeModel; await vi.advanceTimersByTimeAsync(250); expect(prompt.mock.calls.map(([message]) => message)).toEqual(["startup"]); await vi.advanceTimersByTimeAsync(249); expect(prompt).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(1); expect(prompt.mock.calls.map(([message]) => message)).toEqual(["startup", "startup"]); startupAdmission.resolve(undefined); await Promise.all([firstSubmission, duplicate]); expect(prompt.mock.calls.map(([message]) => message)).toEqual(["startup", "startup", "first user prompt"]); expect(editorText).toBe(scenario === "typing" ? "typing during startup wait" : ""); expect(prompt.mock.calls[0]).toEqual([ "startup", { images: undefined, streamingBehavior: "steer", queueIfBusy: true, signal: expect.any(AbortSignal), }, ]); inputDone.resolve(undefined); await expect(run).resolves.toEqual(startupRunResult); } finally { vi.useRealTimers(); } }, );
test("aborts an actually blocked startup admission when the run lifecycle ends", async () => { const inputDone = createDeferred<void>(); let blockedSignal: AbortSignal | undefined; const prompt = vi.fn((_message: string, options?: { signal?: AbortSignal }) => { blockedSignal = options?.signal; return new Promise<void>((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new Error("admission aborted")), { once: true }); }); }); const fakeThis = Object.assign( createSubmitHandlerHarness({ agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, }), createStartupRunHarness( { initialMessage: "startup" }, { getCurrentModel: () => primeModel, getUserInput: vi.fn(() => inputDone.promise), }, ), );
const run = InteractiveMode.prototype.run.call(fakeThis as never); await vi.waitFor(() => expect(prompt).toHaveBeenCalledOnce()); expect(blockedSignal?.aborted).toBe(false); inputDone.resolve(undefined);
await expect(run).resolves.toMatchObject({ type: "agents_view" }); expect(blockedSignal?.aborted).toBe(true); expect(fakeThis.showError).not.toHaveBeenCalled(); });
test.each([false, true])( "lifecycle cancellation retains the submitted draft with exact fidelity (existing stash: %s)", async (hasExistingStash) => { const inputDone = createDeferred<void>(); const store = new ClientPromptStashStore(); const promptStashState = store.forSession("session-a"); if (hasExistingStash) promptStashState.stash = { text: "older durable draft" }; const image = { type: "image", data: "base64", mimeType: "image/png" } as const; const pasteSnapshot = { pastes: [[1, "expanded paste"]], pasteCounter: 2 } as const; const submittedText = "submitted [paste #1] [image #7]"; let editorText = submittedText; const submitHarness = createSubmitHandlerHarness({ editor: { getText: () => editorText, getExpandedText: () => "submitted expanded paste [image #7]", getPasteSnapshot: () => pasteSnapshot, setText: (text) => { editorText = text; }, }, promptStashState, pastedImages: new Map([[7, image]]), getPromptStashImages: () => [[7, image]], }); delete submitHarness.promptStash; const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessage: "startup" }, { getCurrentModel: () => undefined, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, }, ), );
const run = InteractiveMode.prototype.run.call(fakeThis as never); while (!fakeThis.admitPendingStartupPrompts) await Promise.resolve(); fakeThis.latestEditorPromptStash = { text: submittedText, expandedText: "submitted expanded paste [image #7]", pasteSnapshot, images: [[7, image]], }; // pi-tui clears and emits onChange before invoking onSubmit. editorText = ""; const submission = fakeThis.defaultEditor.onSubmit?.("submitted expanded paste [image #7]"); await Promise.resolve(); expect(editorText).toBe(""); fakeThis.returnToAgentsViewRequested = true; fakeThis.isShuttingDown = true; inputDone.resolve(undefined);
await expect(run).resolves.toMatchObject({ type: "agents_view" }); await submission;
expect(fakeThis.agentConnection.prompt).not.toHaveBeenCalled(); expect(editorText).toBe(""); const retained = hasExistingStash ? promptStashState.queuedStashes?.[0] : promptStashState.stash; expect(promptStashState.stash?.text).toBe(hasExistingStash ? "older durable draft" : submittedText); expect(retained).toEqual({ text: submittedText, expandedText: "submitted expanded paste [image #7]", pasteSnapshot, images: [[7, image]], }); }, );
test("a barrier-paused submit retains its draft in the original session's stash after a rebind", async () => { const barrier = createDeferred<"admitted" | "lifecycle-cancelled">(); const oldState: PromptStashState = {}; const newState: PromptStashState = {}; const prompt = vi.fn(async () => {}); const fakeThis = createSubmitHandlerHarness({ promptStashState: oldState, admitPendingStartupPrompts: () => barrier.promise, agentConnection: { prompt, executeBash: vi.fn(async () => {}), getState: vi.fn(async () => ({ isBashRunning: false })), }, }); (fakeThis as unknown as { promptStashSessionId?: string }).promptStashSessionId = "session-old";
const submission = fakeThis.defaultEditor.onSubmit?.("typed for the old session"); await Promise.resolve(); // /new rebinds the live fields while the submit is paused on the barrier. (fakeThis as unknown as { promptStashSessionId?: string }).promptStashSessionId = "session-new"; fakeThis.promptStashState = newState; barrier.resolve("admitted"); await submission;
expect(prompt).not.toHaveBeenCalled(); expect(oldState.stash).toEqual({ text: "typed for the old session" }); expect(newState.stash).toBeUndefined(); });
test("defers stash-store release until lifecycle-retained submissions settle", () => { const store = new ClientPromptStashStore(); const state = store.forSession("session-a"); const fakeThis = { inputSubmissionsPending: 1, pendingPromptStashReleases: [] as { sessionId: string; state: unknown }[], promptStashStore: store, promptStashSessionId: "session-a", promptStashState: state, retainedSubmissionGenerations: new WeakMap(), }; const release = (InteractiveMode.prototype as unknown as { releasePromptStashSession(this: unknown): void }) .releasePromptStashSession; const retain = ( InteractiveMode.prototype as unknown as { retainSubmittedDraft(this: unknown, stash: { text: string }, generation: number): void; } ).retainSubmittedDraft;
release.call(fakeThis); expect(fakeThis.pendingPromptStashReleases).toEqual([{ sessionId: "session-a", state }]); expect(store.forSession("session-a")).toBe(state); retain.call(fakeThis, { text: "retained" }, 1); fakeThis.inputSubmissionsPending = 0; release.call(fakeThis); expect(store.forSession("session-a")).toBe(state); expect(state.stash).toEqual({ text: "retained" }); });
test("a deferred release targets the session captured at deferral, not a rebound one", () => { const store = new ClientPromptStashStore(); const oldState = store.forSession("session-old"); const fakeThis = { inputSubmissionsPending: 1, pendingPromptStashReleases: [] as { sessionId: string; state: unknown }[], promptStashStore: store, promptStashSessionId: "session-old", promptStashState: oldState, }; const methods = InteractiveMode.prototype as unknown as { releasePromptStashSession(this: unknown): void; completeDeferredPromptStashRelease(this: unknown): void; };
methods.releasePromptStashSession.call(fakeThis); const newState = store.forSession("session-new"); newState.stash = { text: "new session draft" }; fakeThis.promptStashSessionId = "session-new"; fakeThis.promptStashState = newState;
fakeThis.inputSubmissionsPending = 0; methods.completeDeferredPromptStashRelease.call(fakeThis);
expect(store.forSession("session-old")).not.toBe(oldState); expect(store.forSession("session-new")).toBe(newState); expect(newState.stash).toEqual({ text: "new session draft" }); });
test("a teardown release after a rebind keeps both deferred session releases", () => { const store = new ClientPromptStashStore(); const oldState = store.forSession("session-old"); const fakeThis = { inputSubmissionsPending: 1, pendingPromptStashReleases: [] as { sessionId: string; state: unknown }[], promptStashStore: store, promptStashSessionId: "session-old", promptStashState: oldState, }; const methods = InteractiveMode.prototype as unknown as { releasePromptStashSession(this: unknown): void; completeDeferredPromptStashRelease(this: unknown): void; };
// /new rebind defers the old session's release... methods.releasePromptStashSession.call(fakeThis); const newState = store.forSession("session-new"); fakeThis.promptStashSessionId = "session-new"; fakeThis.promptStashState = newState; // ...then teardown defers the rebound session's release while the same // submission is still pending. The first pair must not be overwritten. methods.releasePromptStashSession.call(fakeThis); expect(fakeThis.pendingPromptStashReleases.map((pending) => pending.sessionId)).toEqual([ "session-old", "session-new", ]);
fakeThis.inputSubmissionsPending = 0; methods.completeDeferredPromptStashRelease.call(fakeThis);
expect(store.forSession("session-old")).not.toBe(oldState); expect(store.forSession("session-new")).not.toBe(newState); });
test("orders retained drafts by submission rather than rejection", () => { const state: PromptStashState = {}; const fakeThis = { promptStashState: state, retainedSubmissionGenerations: new WeakMap(), }; Object.defineProperty(fakeThis, "promptStash", { get: () => state.stash, set: (stash) => { state.stash = stash; }, }); const retain = ( InteractiveMode.prototype as unknown as { retainSubmittedDraft(this: unknown, stash: { text: string }, generation: number): void; } ).retainSubmittedDraft;
retain.call(fakeThis, { text: "second" }, 2); retain.call(fakeThis, { text: "first" }, 1); expect(state.stash).toEqual({ text: "first" }); expect(state.queuedStashes).toEqual([{ text: "second" }]); });
test("retains every blocked submission in submission order on lifecycle cancellation", async () => { const inputDone = createDeferred<void>(); const promptStashState: PromptStashState = {}; let editorText = "first rich draft"; const submitHarness = createSubmitHandlerHarness({ editor: { getText: () => editorText, setText: (text) => { editorText = text; }, }, promptStashState, }); delete submitHarness.promptStash; const fakeThis = Object.assign( submitHarness, createStartupRunHarness( { initialMessage: "startup" }, { getCurrentModel: () => undefined, getUserInput: vi.fn(() => inputDone.promise), agentConnection: submitHarness.agentConnection, }, ), );
const run = InteractiveMode.prototype.run.call(fakeThis as never); while (!fakeThis.admitPendingStartupPrompts) await Promise.resolve(); fakeThis.latestEditorPromptStash = { text: "first rich draft", expandedText: "first expanded" }; editorText = ""; const first = fakeThis.defaultEditor.onSubmit?.("first rich draft"); fakeThis.latestEditorPromptStash = { text: "second rich draft", expandedText: "second expanded" }; const second = fakeThis.defaultEditor.onSubmit?.("second rich draft"); await Promise.resolve();
fakeThis.returnToAgentsViewRequested = true; fakeThis.isShuttingDown = true; inputDone.resolve(undefined); await expect(run).resolves.toMatchObject({ type: "agents_view" }); await Promise.all([first, second]);
expect(promptStashState.stash).toEqual({ text: "first rich draft", expandedText: "first expanded" }); expect(promptStashState.queuedStashes).toEqual([{ text: "second rich draft", expandedText: "second expanded" }]); });
test("keeps the TUI alive while direct editor submissions own prompt delivery", async () => { const prompt = vi.fn(); const fakeThis = createStartupRunHarness( {}, { getCurrentModel: vi.fn(() => primeModel), getUserInput: vi.fn(async () => undefined), agentConnection: { prompt }, returnToAgentsViewRequested: false, }, );
await expect(InteractiveMode.prototype.run.call(fakeThis as never)).resolves.toEqual(startupRunResult); expect(fakeThis.getUserInput).toHaveBeenCalledOnce(); expect(prompt).not.toHaveBeenCalled(); });
function createPrimeCliHarness(shown: boolean): OnboardingFake { const fakeThis = Object.create(InteractiveMode.prototype) as OnboardingFake; fakeThis.connectionState = createConnectionState({ model: primeModel }); fakeThis.agentConnection = { getAvailableModels: vi.fn(async () => [primeModel]), }; fakeThis.uiServices = { modelRegistry: { authStorage: AuthStorage.inMemory(), refresh: vi.fn(), hasConfiguredAuth: vi.fn(() => true), getProviderAuthStatus: vi.fn( (): AuthStatus => ({ configured: false, source: "prime_cli", }), ), }, settingsManager: { getOnboardingShown: vi.fn(() => shown), setOnboardingShown: vi.fn(), setDefaultModelAndProvider: vi.fn(), flush: vi.fn(async () => {}), }, }; fakeThis.getModelCandidates = vi.fn(async () => [primeModel]); return fakeThis; }
test("shows onboarding when the selected Prime model is backed by Prime CLI auth", () => { const fakeThis = createPrimeCliHarness(false);
expect(shouldRunOnboarding.call(fakeThis)).toBe(true); expect(fakeThis.uiServices.modelRegistry.refresh).toHaveBeenCalledTimes(1); });
test("skips Prime CLI onboarding after it has been shown", () => { const fakeThis = createPrimeCliHarness(true);
expect(shouldRunOnboarding.call(fakeThis)).toBe(false); });
test("persists that onboarding was shown once", () => { const fakeThis = createPrimeCliHarness(false);
markOnboardingShown.call(fakeThis);
expect(fakeThis.uiServices.settingsManager.setOnboardingShown).toHaveBeenCalledWith(true); });
test("persists onboarding before opening the one-shot flow", async () => { let shown = false; let flushed = false; const fakeThis = createPrimeCliHarness(false); fakeThis.uiServices.settingsManager.getOnboardingShown = vi.fn(() => shown); fakeThis.uiServices.settingsManager.setOnboardingShown = vi.fn((nextShown: boolean) => { shown = nextShown; }); fakeThis.uiServices.settingsManager.flush = vi.fn(async () => { flushed = true; }); fakeThis.runOnboardingFlow = vi.fn(async (showPrimeCliSplash?: boolean) => { expect(showPrimeCliSplash).toBe(true); expect(shown).toBe(true); expect(flushed).toBe(true); });
await expect(runStartupOnboarding.call(fakeThis)).resolves.toBe(true);
expect(fakeThis.uiServices.settingsManager.setOnboardingShown).toHaveBeenCalledWith(true); expect(fakeThis.uiServices.settingsManager.flush).toHaveBeenCalledTimes(1); expect(fakeThis.runOnboardingFlow).toHaveBeenCalledWith(true); });
test("cancelled Prime CLI splash exits onboarding before opening configuration", async () => { const fakeThis = createPrimeCliHarness(false); fakeThis.showOnboardingSplash = vi.fn(async () => undefined); fakeThis.showConfigurationMenu = vi.fn(async () => {});
await expect(runOnboardingFlow.call(fakeThis)).resolves.toBeUndefined();
expect(fakeThis.showConfigurationMenu).not.toHaveBeenCalled(); });
test("opens the Models tab after the Prime CLI splash", async () => { const fakeThis = createPrimeCliHarness(false); const configuration = createDeferred<void>(); const dismiss = vi.fn(); fakeThis.showOnboardingSplash = vi.fn(async () => ({ showProgress: vi.fn(), dismiss })); fakeThis.showConfigurationMenu = vi.fn(() => configuration.promise);
const onboarding = runOnboardingFlow.call(fakeThis); await flushAsyncWork();
expect(fakeThis.showConfigurationMenu).toHaveBeenCalledWith("models"); expect(dismiss).not.toHaveBeenCalled();
configuration.resolve(); await expect(onboarding).resolves.toBeUndefined();
expect(dismiss).toHaveBeenCalledTimes(1); });
test("opens the Models tab when models are already available", async () => { const fakeThis = createPrimeCliHarness(false); fakeThis.connectionState = createConnectionState({ model: undefined }); fakeThis.getModelCandidates = vi.fn(async () => [primeModel]); fakeThis.showConfigurationMenu = vi.fn(async () => {});
await expect(runOnboardingFlow.call(fakeThis, false)).resolves.toBeUndefined();
expect(fakeThis.getModelCandidates).toHaveBeenCalledTimes(1); expect(fakeThis.showConfigurationMenu).toHaveBeenCalledWith("models"); });
test("opens Prime login before the Models tab when no models are available", async () => { const fakeThis = createPrimeCliHarness(false); fakeThis.connectionState = createConnectionState({ model: undefined }); fakeThis.getModelCandidates = vi.fn(async () => []); const showProgress = vi.fn(); const dismiss = vi.fn(); fakeThis.showOnboardingSplash = vi.fn(async () => ({ showProgress, dismiss })); fakeThis.createAuthFlows = vi.fn(() => ({ runPrimeInferenceLogin: vi.fn(async () => ({ status: "success" as const, providerId: PRIME_INFERENCE_PROVIDER_ID, providerName: "Prime Inference", authType: "api_key" as const, kind: "provider" as const, })), })); fakeThis.prepareForModelSelectionAfterLogin = vi.fn(async () => true); const configuration = createDeferred<void>(); fakeThis.showConfigurationMenu = vi.fn(() => configuration.promise);
const onboarding = runOnboardingFlow.call(fakeThis, false); await flushAsyncWork();
expect(fakeThis.showOnboardingSplash).toHaveBeenCalledWith(); expect(showProgress).toHaveBeenNthCalledWith(1, "Signing in to Prime Intellect..."); expect(showProgress).toHaveBeenNthCalledWith(2, "Preparing models..."); expect(fakeThis.prepareForModelSelectionAfterLogin).toHaveBeenCalledTimes(1); expect(fakeThis.showConfigurationMenu).toHaveBeenCalledWith("models"); expect(dismiss).not.toHaveBeenCalled();
configuration.resolve(); await expect(onboarding).resolves.toBeUndefined();
expect(dismiss).toHaveBeenCalledTimes(1); });});
describe("InteractiveMode post-login model preparation", () => { type LoginHarness = { prepareForModelSelectionAfterLogin(authResult: AuthenticationResult): Promise<boolean>; invalidateConnectionModels(): void; getCurrentModel(): AgentConnectionModel | undefined; applySelectedModel(model: AgentConnectionModel): Promise<void>; showError(message: string): void; uiServices: { modelRegistry: Pick<ModelRegistry, "find">; settingsManager: { flush(): Promise<void>; }; }; };
const prepareForModelSelectionAfterLogin = (InteractiveMode.prototype as unknown as LoginHarness) .prepareForModelSelectionAfterLogin; const loginPrimeModel: AgentConnectionModel = { id: "openai/gpt-5.5", name: "GPT-5.5", api: "openai-completions", provider: PRIME_INFERENCE_PROVIDER_ID, baseUrl: "https://api.pinference.ai/api/v1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000, } as AgentConnectionModel;
test("persists GLM 5.2 before model selection after Prime Inference login", async () => { const fallbackModel = { ...loginPrimeModel, id: "z-ai/glm-5.2", name: "GLM 5.2" }; const flushSettings = vi.fn(async () => {}); const fakeThis = Object.create(InteractiveMode.prototype) as LoginHarness; fakeThis.invalidateConnectionModels = vi.fn(); fakeThis.getCurrentModel = vi.fn(() => undefined); fakeThis.applySelectedModel = vi.fn(async () => {}); fakeThis.showError = vi.fn(); fakeThis.uiServices = { modelRegistry: { find: vi.fn(() => fallbackModel), }, settingsManager: { flush: flushSettings, }, };
await expect( prepareForModelSelectionAfterLogin.call(fakeThis, { status: "success", providerId: PRIME_INFERENCE_PROVIDER_ID, providerName: "Prime Inference", authType: "api_key", kind: "provider", }), ).resolves.toBe(true);
expect(fakeThis.invalidateConnectionModels).not.toHaveBeenCalled(); expect(fakeThis.applySelectedModel).toHaveBeenCalledWith(fallbackModel); expect(flushSettings).toHaveBeenCalledTimes(1); });
test("preserves refreshed models after any model-provider login", async () => { const fakeThis = Object.create(InteractiveMode.prototype) as LoginHarness; fakeThis.invalidateConnectionModels = vi.fn(); fakeThis.getCurrentModel = vi.fn(() => loginPrimeModel); fakeThis.applySelectedModel = vi.fn(async () => {}); fakeThis.showError = vi.fn(); fakeThis.uiServices = { modelRegistry: { find: vi.fn(() => undefined), }, settingsManager: { flush: vi.fn(async () => {}), }, };
await expect( prepareForModelSelectionAfterLogin.call(fakeThis, { status: "success", providerId: "anthropic", providerName: "Anthropic", authType: "oauth", kind: "provider", }), ).resolves.toBe(false);
expect(fakeThis.invalidateConnectionModels).not.toHaveBeenCalled(); expect(fakeThis.applySelectedModel).not.toHaveBeenCalled(); });});
describe("InteractiveMode splash cwd display", () => { test("formats home-relative cwd paths", () => { expect(formatSplashCwd(homedir())).toBe("~"); expect(formatSplashCwd(path.join(homedir(), "pi", "prime-agent"))).toBe("~/pi/prime-agent"); });
test("keeps worktree paths as cwd paths instead of repo branch labels", () => { expect(formatSplashCwd(path.join(homedir(), "pi", "prime-agent", ".worktrees", "improve-onboarding"))).toBe( "~/pi/prime-agent/.worktrees/improve-onboarding", ); });});
describe("InteractiveMode goal status announcements", () => { test("does not announce active goal usage-only updates", () => { type GoalAnnouncementHarness = { lastGoalAnnouncement?: unknown; setGoalAnnouncementBaseline(goal: GoalState): void; shouldAnnounceGoalUpdate(goal: GoalState): boolean; }; const fakeThis = Object.create(InteractiveMode.prototype) as GoalAnnouncementHarness; const activeGoal: GoalState = { active: true, status: "active", goalId: "goal-1", objective: "ship the feature", tokensUsed: 10, timeUsedSeconds: 5, continuationsUsed: 1, };
fakeThis.setGoalAnnouncementBaseline(emptyGoalState()); expect(fakeThis.shouldAnnounceGoalUpdate(activeGoal)).toBe(true); expect( fakeThis.shouldAnnounceGoalUpdate({ ...activeGoal, tokensUsed: 20, timeUsedSeconds: 15, continuationsUsed: 2, }), ).toBe(false); expect( fakeThis.shouldAnnounceGoalUpdate({ ...activeGoal, objective: "ship the feature and update docs", }), ).toBe(false); });
test("announces a transition back to idle when a goal is cleared", () => { type GoalAnnouncementHarness = { setGoalAnnouncementBaseline(goal: GoalState): void; shouldAnnounceGoalUpdate(goal: GoalState): boolean; }; const fakeThis = Object.create(InteractiveMode.prototype) as GoalAnnouncementHarness; fakeThis.setGoalAnnouncementBaseline({ active: true, status: "active", goalId: "goal-1", objective: "ship the feature", tokensUsed: 10, timeUsedSeconds: 5, continuationsUsed: 1, });
expect(fakeThis.shouldAnnounceGoalUpdate(emptyGoalState())).toBe(true); expect(fakeThis.shouldAnnounceGoalUpdate(emptyGoalState())).toBe(false); });});
describe("InteractiveMode tray goal label", () => { type TrayUsage = { contextWindow: number; tokens: number | null; percent: number | null; promptCache?: PromptCacheSnapshot; }; type TrayLabelHarness = { heartbeatCatalog: AgentConnectionHeartbeat[]; subagentSnapshots: Map<string, never>; connectionState: { activeSessionId: string; sessionId: string; goal?: GoalState; heartbeat?: AgentCronJob | null; contextUsage: TrayUsage | undefined; }; uiServices: { getContextUsage(): TrayUsage | undefined }; isAgentStreaming?(): boolean; getTrayContextLabel(): string | undefined; }; const getTrayContextLabel = (InteractiveMode.prototype as unknown as TrayLabelHarness).getTrayContextLabel;
function createHeartbeat(status: AgentCronJob["status"]): AgentCronJob { return { id: "heartbeat-1", status, source: "heartbeat", activeSessionId: "active-1", sessionId: "session-1", sessionFile: "/tmp/session.jsonl", cwd: "/tmp/project", prompt: "check whether there is follow-up work", schedule: { kind: "interval", expression: "every 5m", intervalMs: 300_000 }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", nextRunAt: "2026-01-01T00:05:00.000Z", runCount: 0, }; }
test("shows active goals in the lower tray without an objective", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "a long objective that should not render in the tray", tokensUsed: 0, timeUsedSeconds: 65, continuationsUsed: 1, } satisfies GoalState, contextUsage: undefined, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("Pursuing goal (1m 05s)"); });
test("combines active goals with token/context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task", tokensUsed: 0, timeUsedSeconds: 65, continuationsUsed: 1, } satisfies GoalState, contextUsage: { contextWindow: 100_000, tokens: 75_000, percent: 75 }, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("Pursuing goal (1m 05s) · 75k (75%)"); });
test("formats cached tokens as slash ratio when promptCache is warm", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.isAgentStreaming = () => false; fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", contextUsage: { contextWindow: 100_000, tokens: 75_000, percent: 75, promptCache: { status: "warm", cachedTokens: 50_000, ttlSeconds: 300, expiresAt: Date.now() + 240_000, deterministic: true, }, }, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("50k/75k cached (75%) · ttl 4m"); });
test("retains cached ratio and ttl label when agent is streaming", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.isAgentStreaming = () => true; fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", contextUsage: { contextWindow: 100_000, tokens: 75_000, percent: 75, promptCache: { status: "warm", cachedTokens: 50_000, ttlSeconds: 300, expiresAt: Date.now() + 240_000, deterministic: true, }, }, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("50k/75k cached (75%) · ttl 4m"); });
test("combines active goals, active heartbeats, and context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = [{ job: createHeartbeat("active") }]; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task", tokensUsed: 0, timeUsedSeconds: 65, continuationsUsed: 1, } satisfies GoalState, heartbeat: createHeartbeat("active"), contextUsage: { contextWindow: 100_000, tokens: 75_000, percent: 75 }, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("Pursuing goal (1m 05s) · 1 heartbeat · 75k (75%)"); });
test("omits the usage segment when token count is unknown", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; fakeThis.heartbeatCatalog = []; fakeThis.subagentSnapshots = new Map<string, never>(); fakeThis.connectionState = { activeSessionId: "active-1", sessionId: "session-1", goal: { active: true, status: "active", objective: "finish the task", tokensUsed: 0, timeUsedSeconds: 65, continuationsUsed: 1, } satisfies GoalState, contextUsage: { contextWindow: 100_000, tokens: null, percent: null }, }; fakeThis.uiServices = { getContextUsage: () => undefined };
expect(getTrayContextLabel.call(fakeThis)).toBe("Pursuing goal (1m 05s)"); });});
describe("InteractiveMode live context usage", () => { type LiveContextHarness = { connectionState: Pick<AgentConnectionState, "contextUsage"> | undefined; activityTracker: { getStatus(): { tokens: number } }; isAgentStreaming(): boolean; contextUsageTokenBaseline: number; getConnectionContextUsage(): AgentConnectionState["contextUsage"]; }; const prototype = InteractiveMode.prototype as unknown as LiveContextHarness;
function createHarness( opts: { streaming?: boolean; inFlight?: number; baseline?: number } = {}, ): LiveContextHarness { const fakeThis = Object.create(InteractiveMode.prototype) as LiveContextHarness; fakeThis.connectionState = { contextUsage: undefined }; fakeThis.activityTracker = { getStatus: () => ({ tokens: opts.inFlight ?? 0 }) }; fakeThis.isAgentStreaming = () => opts.streaming ?? false; fakeThis.contextUsageTokenBaseline = opts.baseline ?? 0; return fakeThis; }
test("returns the snapshot verbatim when idle", () => { const fakeThis = createHarness(); fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: 42_000, percent: 42 } };
expect(prototype.getConnectionContextUsage.call(fakeThis)).toEqual({ contextWindow: 100_000, tokens: 42_000, percent: 42, }); });
test("adds in-flight streaming output to the snapshot baseline while streaming", () => { const fakeThis = createHarness({ streaming: true, inFlight: 3_000 }); fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: 42_000, percent: 42 } };
// 42k baseline + 3k in-flight = 45k; ticks up live as the model writes. expect(prototype.getConnectionContextUsage.call(fakeThis)).toMatchObject({ tokens: 45_000, percent: 45 }); });
test("only adds output beyond the refresh baseline (auto-retry does not double count)", () => { // Tracker holds 5k from a failed attempt (baseline), now 6k after 1k of the retry. const fakeThis = createHarness({ streaming: true, inFlight: 6_000, baseline: 5_000 }); fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: 42_000, percent: 42 } };
// Only the 1k generated since the refresh is in-flight, not the failed attempt's 5k. expect(prototype.getConnectionContextUsage.call(fakeThis)).toMatchObject({ tokens: 43_000 }); });
test("does not add in-flight output when not streaming", () => { const fakeThis = createHarness({ streaming: false, inFlight: 3_000 }); fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: 42_000, percent: 42 } };
expect(prototype.getConnectionContextUsage.call(fakeThis)).toMatchObject({ tokens: 42_000 }); });
test("passes through an unknown (post-compaction) snapshot without inflating it", () => { const fakeThis = createHarness({ streaming: true, inFlight: 3_000 }); fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: null, percent: null } };
expect(prototype.getConnectionContextUsage.call(fakeThis)).toMatchObject({ tokens: null, percent: null }); });
test("returns undefined when there is no snapshot yet", () => { const fakeThis = createHarness({ streaming: true, inFlight: 3_000 }); fakeThis.connectionState = { contextUsage: undefined };
expect(prototype.getConnectionContextUsage.call(fakeThis)).toBeUndefined(); });
test("incorporates streaming message cache and input usage in-flight", () => { const fakeThis = createHarness({ streaming: true, inFlight: 500 }) as LiveContextHarness & { streamingMessage?: unknown; getCurrentModel?: () => unknown; }; fakeThis.connectionState = { contextUsage: { contextWindow: 100_000, tokens: 10_000, percent: 10 } }; fakeThis.streamingMessage = { provider: "anthropic", model: "claude-3-5-sonnet", usage: { input: 20_000, cacheRead: 15_000, cacheWrite: 0, output: 0, }, }; fakeThis.getCurrentModel = () => ({ provider: "anthropic", id: "claude-3-5-sonnet", contextWindow: 100_000 });
const usage = prototype.getConnectionContextUsage.call(fakeThis); expect(usage).toMatchObject({ tokens: 35_500, percent: 35.5, promptCache: expect.objectContaining({ status: "warm", cachedTokens: 15_000, }), }); });
type RefreshHarness = { agentConnection: { getSessionStats(): Promise<{ contextUsage: unknown } | undefined> }; connectionState: { sessionId?: string; contextUsage?: unknown }; activityTracker: { getStatus(): { tokens: number } }; contextUsageTokenBaseline: number; contextUsageRefresh: { generation: number; lastSuccessGeneration: number }; patchConnectionState(patch: Record<string, unknown>): void; refreshConnectionContextUsage(): Promise<void>; }; const refresh = (InteractiveMode.prototype as unknown as RefreshHarness).refreshConnectionContextUsage;
function createRefreshHarness( getSessionStats: RefreshHarness["agentConnection"]["getSessionStats"], ): RefreshHarness & { patched: Record<string, unknown>[] } { const fakeThis = Object.create(InteractiveMode.prototype) as RefreshHarness & { patched: Record<string, unknown>[]; }; fakeThis.patched = []; fakeThis.activityTracker = { getStatus: () => ({ tokens: 0 }) }; fakeThis.contextUsageTokenBaseline = 0; fakeThis.contextUsageRefresh = { generation: 0, lastSuccessGeneration: 0 }; fakeThis.connectionState = { sessionId: "session-A", contextUsage: undefined }; fakeThis.patchConnectionState = (patch) => fakeThis.patched.push(patch); fakeThis.agentConnection = { getSessionStats }; return fakeThis; }
test("refreshConnectionContextUsage drops stale stats after a session switch", async () => { let fakeThis: ReturnType<typeof createRefreshHarness>; fakeThis = createRefreshHarness(async () => { // User switches sessions while the stats call is in flight. fakeThis.connectionState = { sessionId: "session-B", contextUsage: undefined }; return { contextUsage: { contextWindow: 100_000, tokens: 50_000, percent: 50 } }; });
await refresh.call(fakeThis);
// Stats belonged to session-A; must not overwrite session-B. expect(fakeThis.patched).toHaveLength(0); });
test("refreshConnectionContextUsage keeps a newer successful same-session response", async () => { const first = createDeferred<{ contextUsage: unknown }>(); const second = createDeferred<{ contextUsage: unknown }>(); let request = 0; const fakeThis = createRefreshHarness(() => (++request === 1 ? first.promise : second.promise));
const olderRefresh = refresh.call(fakeThis); const newerRefresh = refresh.call(fakeThis); second.resolve({ contextUsage: { tokens: 10, contextWindow: 100, percent: 10 } }); await newerRefresh; first.resolve({ contextUsage: { tokens: 90, contextWindow: 100, percent: 90 } }); await olderRefresh;
expect(fakeThis.patched).toEqual([{ contextUsage: { tokens: 10, contextWindow: 100, percent: 10 } }]); });
test.each(["failure", "no stats"] as const)( "refreshConnectionContextUsage lets an older successful response survive a newer %s", async (newerResult) => { const first = createDeferred<{ contextUsage: unknown }>(); let request = 0; const fakeThis = createRefreshHarness(() => { if (++request === 1) return first.promise; return newerResult === "failure" ? Promise.reject(new Error("stats unavailable")) : Promise.resolve(undefined); });
const olderRefresh = refresh.call(fakeThis); await refresh.call(fakeThis); first.resolve({ contextUsage: { tokens: 90, contextWindow: 100, percent: 90 } }); await olderRefresh;
expect(fakeThis.patched).toEqual([{ contextUsage: { tokens: 90, contextWindow: 100, percent: 90 } }]); }, );
test("harvestAssistantMessageUsage updates tokens and prompt cache", () => { type HarvestHarness = { connectionState?: { contextUsage?: unknown }; activityTracker?: { getStatus(): { tokens: number } }; contextUsageTokenBaseline?: number; getCurrentModel?: () => unknown; patchConnectionState(patch: Record<string, unknown>): void; harvestAssistantMessageUsage(message: unknown): void; }; const harvest = (InteractiveMode.prototype as unknown as HarvestHarness).harvestAssistantMessageUsage; const patched: Record<string, unknown>[] = []; const fakeThis: HarvestHarness = { connectionState: { contextUsage: { contextWindow: 100_000, tokens: 5_000, percent: 5 } }, activityTracker: { getStatus: () => ({ tokens: 100 }) }, contextUsageTokenBaseline: 0, getCurrentModel: () => ({ provider: "anthropic", id: "claude-3-5-sonnet", contextWindow: 100_000 }), patchConnectionState: (patch) => patched.push(patch), harvestAssistantMessageUsage: harvest, }; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype);
harvest.call(fakeThis, { role: "assistant", provider: "anthropic", model: "claude-3-5-sonnet", usage: { input: 20_000, cacheRead: 10_000, cacheWrite: 0, output: 200, }, });
expect(patched).toHaveLength(1); expect(patched[0]).toMatchObject({ contextUsage: { tokens: 30_200, percent: 30.2, promptCache: expect.objectContaining({ status: "warm", cachedTokens: 10_000, }), }, }); });});
describe("truncatePathMiddle", () => { test("does not duplicate the home prefix for short tilde paths", () => { const value = truncatePathMiddle("~/myproject", 8);
expect(value).toMatch(/^~\//); expect(value).not.toContain("/~/"); expect(visibleWidth(value)).toBeLessThanOrEqual(8); });});
describe("InteractiveMode.setToolsExpanded", () => { function createExpansionFakeThis(chatChildren: unknown[]): any { const fakeThis: any = { toolOutputExpanded: false, agentMessagesExpanded: false, editDiffsExpanded: false, customHeader: undefined, builtInHeader: { setExpanded: vi.fn() }, chatContainer: { children: chatChildren }, ui: { requestRender: vi.fn(), requestRenderPreservingViewport: vi.fn(), isFullscreen: vi.fn().mockReturnValue(false), }, }; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); return fakeThis; }
test("applies expansion state to the active header and chat entries", () => { const chatChild = { setExpanded: vi.fn() }; const fakeThis = createExpansionFakeThis([chatChild]);
fakeThis.setToolsExpanded(true);
expect(fakeThis.toolOutputExpanded).toBe(true); expect(fakeThis.builtInHeader.setExpanded).toHaveBeenCalledWith(true); expect(chatChild.setExpanded).toHaveBeenCalledWith(true); // Expansion keeps the user anchored, so it uses the viewport-preserving path. expect(fakeThis.ui.requestRenderPreservingViewport).toHaveBeenCalledTimes(1); });
test("toggles agent messages separately from tools", () => { const toolChild = { setExpanded: vi.fn() }; const ipythonChild = { setExpanded: vi.fn(), setAgentMessagesExpanded: vi.fn(), setEditDiffsExpanded: vi.fn() }; const messageChild = new AgentMessageComponent({ role: "custom", customType: "agent_message", content: "Ping.", display: true, details: { id: "agentmsg_split", message: "Ping.", from: null }, timestamp: 123, } as any); const messageSetExpanded = vi.spyOn(messageChild, "setExpanded"); const fakeThis = createExpansionFakeThis([toolChild, ipythonChild, messageChild]);
fakeThis.toggleAgentMessageExpansion();
expect(fakeThis.agentMessagesExpanded).toBe(true); expect(fakeThis.toolOutputExpanded).toBe(false); expect(messageSetExpanded).toHaveBeenCalledWith(true); expect(toolChild.setExpanded).toHaveBeenCalledWith(false); expect(ipythonChild.setExpanded).toHaveBeenCalledWith(false); expect(ipythonChild.setAgentMessagesExpanded).toHaveBeenCalledWith(true);
fakeThis.setToolsExpanded(true);
expect(toolChild.setExpanded).toHaveBeenCalledWith(true); expect(messageSetExpanded).toHaveBeenLastCalledWith(true); expect(ipythonChild.setExpanded).toHaveBeenCalledWith(true); expect(ipythonChild.setAgentMessagesExpanded).toHaveBeenLastCalledWith(true); expect(fakeThis.agentMessagesExpanded).toBe(true); });
test("toggles edit diffs separately from tools and agent messages", () => { const child = { setExpanded: vi.fn(), setAgentMessagesExpanded: vi.fn(), setEditDiffsExpanded: vi.fn() }; const fakeThis = createExpansionFakeThis([child]);
fakeThis.toggleEditDiffExpansion();
expect(fakeThis.editDiffsExpanded).toBe(true); expect(fakeThis.toolOutputExpanded).toBe(false); expect(fakeThis.agentMessagesExpanded).toBe(false); expect(child.setEditDiffsExpanded).toHaveBeenCalledWith(true); expect(child.setExpanded).toHaveBeenCalledWith(false); expect(child.setAgentMessagesExpanded).toHaveBeenCalledWith(false);
fakeThis.setToolsExpanded(true);
expect(fakeThis.editDiffsExpanded).toBe(true); expect(child.setEditDiffsExpanded).toHaveBeenLastCalledWith(true); expect(child.setExpanded).toHaveBeenLastCalledWith(true); });});
describe("InteractiveMode.createExtensionUIContext setTheme", () => { test("persists theme changes to settings manager", () => { initTheme("dark");
let currentTheme = "dark"; const settingsManager = { getTheme: vi.fn(() => currentTheme), setTheme: vi.fn((theme: string) => { currentTheme = theme; }), }; const fakeThis: any = { session: { settingsManager }, settingsManager, ui: { requestRender: vi.fn() }, };
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); const result = uiContext.setTheme("light");
expect(result.success).toBe(true); expect(settingsManager.setTheme).toHaveBeenCalledWith("light"); expect(currentTheme).toBe("light"); expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1); });
test("does not persist invalid theme names", () => { initTheme("dark");
const settingsManager = { getTheme: vi.fn(() => "dark"), setTheme: vi.fn(), }; const fakeThis: any = { session: { settingsManager }, settingsManager, ui: { requestRender: vi.fn() }, };
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); const result = uiContext.setTheme("__missing_theme__");
expect(result.success).toBe(false); expect(settingsManager.setTheme).not.toHaveBeenCalled(); expect(fakeThis.ui.requestRender).not.toHaveBeenCalled(); });});
describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { test("stores wrapper factories and rebuilds autocomplete immediately", () => { const wrapper: AutocompleteProviderFactory = (current) => current; const fakeThis = { autocompleteProviderWrappers: [] as AutocompleteProviderFactory[], setupAutocompleteProvider: vi.fn(), };
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); uiContext.addAutocompleteProvider(wrapper);
expect(fakeThis.autocompleteProviderWrappers).toEqual([wrapper]); expect(fakeThis.setupAutocompleteProvider).toHaveBeenCalledTimes(1); });});
describe("InteractiveMode.setupAutocompleteProvider", () => { test("stacks wrapper factories over a fresh base provider", () => { const defaultEditor = { setAutocompleteProvider: vi.fn() }; const customEditor = { setAutocompleteProvider: vi.fn() }; const calls: string[] = [];
const wrap1: AutocompleteProviderFactory = (current): AutocompleteProvider => ({ async getSuggestions(lines, cursorLine, cursorCol, options) { calls.push("getSuggestions:wrap1"); return current.getSuggestions(lines, cursorLine, cursorCol, options); }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { calls.push("applyCompletion:wrap1"); return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { calls.push("shouldTrigger:wrap1"); return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, }); const wrap2: AutocompleteProviderFactory = (current): AutocompleteProvider => ({ async getSuggestions(lines, cursorLine, cursorCol, options) { calls.push("getSuggestions:wrap2"); return current.getSuggestions(lines, cursorLine, cursorCol, options); }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { calls.push("applyCompletion:wrap2"); return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { calls.push("shouldTrigger:wrap2"); return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, });
const fakeThis = { createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), defaultEditor, editor: customEditor, autocompleteProviderWrappers: [wrap1, wrap2], };
(InteractiveMode as any).prototype.setupAutocompleteProvider.call(fakeThis);
expect(defaultEditor.setAutocompleteProvider).toHaveBeenCalledTimes(1); expect(customEditor.setAutocompleteProvider).toHaveBeenCalledTimes(1); const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; expect(provider).toBe(customEditor.setAutocompleteProvider.mock.calls[0]?.[0]); expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); });});
describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark"); });
function createShowLoadedResourcesThis(options: { quietStartup: boolean; verbose?: boolean; toolOutputExpanded?: boolean; cwd?: string; contextFiles?: Array<{ path: string; content?: string }>; extensions?: ExtensionFixture[]; skills?: Array<{ filePath: string; name: string }>; skillDiagnostics?: AgentConnectionResourceDiagnostic[]; useRealScopeGroups?: boolean; useRealDiagnostics?: boolean; }) { const connectionResourceSnapshot: AgentConnectionResourceSnapshot = { contextFiles: (options.contextFiles ?? []).map((contextFile) => ({ path: contextFile.path })), skills: options.skills ?? [], prompts: [], extensions: options.extensions ?? [], themes: [], diagnostics: { skills: options.skillDiagnostics ?? [], prompts: [], extensions: [], themes: [], }, }; const extensionRunner = { getCommandDiagnostics: () => [], getShortcutDiagnostics: () => [], }; const fakeThis: any = { options: { verbose: options.verbose ?? false }, toolOutputExpanded: options.toolOutputExpanded ?? false, chatContainer: new Container(), settingsManager: { getQuietStartup: () => options.quietStartup, }, sessionManager: { getCwd: () => options.cwd ?? "/tmp/project", }, connectionResourceSnapshot, extensionRunner, formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p), formatExtensionDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatExtensionDisplayPath.call(fakeThis, p), formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p), getCurrentCwd: () => options.cwd ?? "/tmp/project", getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis), buildScopeGroups: () => [], formatScopeGroups: () => "resource-list", isPackageSource: (sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.isPackageSource.call(fakeThis, sourceInfo), getShortPath: (p: string, sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.getShortPath.call(fakeThis, p, sourceInfo), getCompactPathLabel: (p: string, sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.getCompactPathLabel.call(fakeThis, p, sourceInfo), getCompactPackageSourceLabel: (sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.getCompactPackageSourceLabel.call(fakeThis, sourceInfo), getCompactExtensionLabel: (p: string, sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.getCompactExtensionLabel.call(fakeThis, p, sourceInfo), getCompactDisplayPathSegments: (p: string) => (InteractiveMode as any).prototype.getCompactDisplayPathSegments.call(fakeThis, p), getCompactNonPackageExtensionLabel: ( p: string, index: number, allPaths: Array<{ path: string; segments: string[] }>, ) => (InteractiveMode as any).prototype.getCompactNonPackageExtensionLabel.call(fakeThis, p, index, allPaths), getCompactExtensionLabels: (extensions: ExtensionFixture[]) => (InteractiveMode as any).prototype.getCompactExtensionLabels.call(fakeThis, extensions), formatDiagnostics: () => "diagnostics", getBuiltInCommandConflictDiagnostics: () => [], };
if (options.useRealDiagnostics) { fakeThis.formatDiagnostics = ( diagnostics: readonly AgentConnectionResourceDiagnostic[], sourceInfos: Map<string, AgentConnectionSourceInfo>, ) => (InteractiveMode as any).prototype.formatDiagnostics.call(fakeThis, diagnostics, sourceInfos); }
if (options.useRealScopeGroups) { fakeThis.getScopeGroup = (sourceInfo?: AgentConnectionSourceInfo) => (InteractiveMode as any).prototype.getScopeGroup.call(fakeThis, sourceInfo); fakeThis.buildScopeGroups = (items: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>) => (InteractiveMode as any).prototype.buildScopeGroups.call(fakeThis, items); fakeThis.formatScopeGroups = (groups: unknown, formatOptions: unknown) => (InteractiveMode as any).prototype.formatScopeGroups.call(fakeThis, groups, formatOptions); }
return fakeThis; }
function createSourceInfo( filePath: string, options: { source: string; scope: "user" | "project" | "temporary"; origin: "package" | "top-level"; baseDir?: string; }, ): AgentConnectionSourceInfo { return { path: filePath, source: options.source, scope: options.scope, origin: options.origin, baseDir: options.baseDir, }; }
function createExtensionFixtures(): ExtensionFixture[] { return [ { path: "/tmp/project/.pi/extensions/answer.ts", sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/answer.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/project/.pi/extensions", }), }, { path: "/tmp/project/.pi/extensions/local-index/index.ts", sourceInfo: createSourceInfo("/tmp/project/.pi/extensions/local-index/index.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/project/.pi/extensions", }), }, { path: "/tmp/agent/extensions/user-index/index.ts", sourceInfo: createSourceInfo("/tmp/agent/extensions/user-index/index.ts", { source: "local", scope: "user", origin: "top-level", baseDir: "/tmp/agent/extensions", }), }, { path: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", { source: "npm:pi-markdown-preview", scope: "project", origin: "package", baseDir: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview", }), }, { path: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts", sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/@scope/pi-scoped/extensions/index.ts", { source: "npm:@scope/pi-scoped", scope: "project", origin: "package", baseDir: "/tmp/project/.pi/npm/node_modules/@scope/pi-scoped", }), }, { path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts", sourceInfo: createSourceInfo( "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/index.ts", { source: "git:github.com/HazAT/pi-interactive-subagents", scope: "project", origin: "package", baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents", }, ), }, { path: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts", sourceInfo: createSourceInfo( "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents/extensions/subagents/index.ts", { source: "git:github.com/HazAT/pi-interactive-subagents", scope: "project", origin: "package", baseDir: "/tmp/project/.pi/git/github.com/HazAT/pi-interactive-subagents", }, ), }, { path: "/tmp/temp/cli-extension.ts", sourceInfo: createSourceInfo("/tmp/temp/cli-extension.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/temp", }), }, ]; }
test("does not show resource listing by default", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: false, });
expect(fakeThis.chatContainer.children).toHaveLength(0); });
test("shows a compact resource listing when forced", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
const output = renderAll(fakeThis.chatContainer); expect(output).toContain("[Skills]"); expect(output).toContain("commit"); expect(output).not.toContain("resource-list"); });
test("shows full resource listing when expanded", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, toolOutputExpanded: true, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
const output = renderAll(fakeThis.chatContainer); expect(output).toContain("[Skills]"); expect(output).toContain("resource-list"); expect(output).not.toContain("commit"); });
test("shows full resource listing on verbose startup even when tool output is collapsed", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: true, verbose: true, toolOutputExpanded: false, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: false, });
const output = renderAll(fakeThis.chatContainer); expect(output).toContain("[Skills]"); expect(output).toContain("resource-list"); expect(output).not.toContain("commit"); });
test("abbreviates extensions in compact listing", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
const output = renderAll(fakeThis.chatContainer); expect(output).toContain("[Extensions]"); expect(output).toContain("answer.ts, btw.ts"); expect(output).not.toContain("extensions/answer.ts"); });
test("captures mixed extension layouts in compact output", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions: createExtensionFixtures(), useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] @scope/pi-scoped, answer.ts, cli-extension.ts, HazAT/pi-interactive-subagents, HazAT/pi-interactive-subagents:subagents, local-index, pi-markdown-preview, user-index"`); });
test("adds more parent folders until local extension labels are unique", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/alpha/one/index.ts", sourceInfo: createSourceInfo("/tmp/alpha/one/index.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/alpha", }), }, { path: "/tmp/beta/one/index.ts", sourceInfo: createSourceInfo("/tmp/beta/one/index.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/beta", }), }, { path: "/tmp/gamma/one/index.ts", sourceInfo: createSourceInfo("/tmp/gamma/one/index.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/gamma", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] alpha/one, beta/one, gamma/one"`); });
test("strips index.ts from local extension label, showing parent dir", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/extensions/plan-mode/index.ts", sourceInfo: createSourceInfo("/tmp/extensions/plan-mode/index.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] plan-mode"`); });
test("strips index.js from local extension label, showing parent dir", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/extensions/plan-mode/index.js", sourceInfo: createSourceInfo("/tmp/extensions/plan-mode/index.js", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] plan-mode"`); });
test("mixed single-file and subdirectory index.ts extensions strip index.ts", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/extensions/webfetch.ts", sourceInfo: createSourceInfo("/tmp/extensions/webfetch.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, { path: "/tmp/extensions/plan-mode/index.ts", sourceInfo: createSourceInfo("/tmp/extensions/plan-mode/index.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] plan-mode, webfetch.ts"`); });
test("multiple index.ts with unique parent dirs need no disambiguation", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/extensions/foo/index.ts", sourceInfo: createSourceInfo("/tmp/extensions/foo/index.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, { path: "/tmp/extensions/bar/index.ts", sourceInfo: createSourceInfo("/tmp/extensions/bar/index.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] bar, foo"`); });
test("multiple index.ts with same parent dir name disambiguated with grandparent", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/alpha/tools/index.ts", sourceInfo: createSourceInfo("/tmp/alpha/tools/index.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/alpha", }), }, { path: "/tmp/beta/tools/index.ts", sourceInfo: createSourceInfo("/tmp/beta/tools/index.ts", { source: "cli", scope: "temporary", origin: "top-level", baseDir: "/tmp/beta", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] alpha/tools, beta/tools"`); });
test("non-index file in subdirectory stays as filename", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/extensions/my-ext/main.ts", sourceInfo: createSourceInfo("/tmp/extensions/my-ext/main.ts", { source: "local", scope: "project", origin: "top-level", baseDir: "/tmp/extensions", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] main.ts"`); });
test("package extensions still strip index.ts correctly (regression guard)", () => { const extensions: ExtensionFixture[] = [ { path: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", sourceInfo: createSourceInfo("/tmp/project/.pi/npm/node_modules/pi-markdown-preview/extensions/index.ts", { source: "npm:pi-markdown-preview", scope: "project", origin: "package", baseDir: "/tmp/project/.pi/npm/node_modules/pi-markdown-preview", }), }, ];
const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, extensions, useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] pi-markdown-preview"`); }); test("captures mixed extension layouts in expanded output", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, toolOutputExpanded: true, extensions: createExtensionFixtures(), useRealScopeGroups: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`"[Extensions] project /tmp/project/.pi/extensions/answer.ts /tmp/project/.pi/extensions/local-index git:github.com/HazAT/pi-interactive-subagents extensions extensions/subagents npm:@scope/pi-scoped extensions npm:pi-markdown-preview extensions user /tmp/agent/extensions/user-index path /tmp/temp/cli-extension.ts"`); });
test("shows context paths relative to cwd while preserving full external paths", () => { const home = homedir(); const cwd = path.join(home, "Development", "pi-mono"); const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, cwd, contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/"); expect(output).toContain("[Context]"); expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md"); expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`); });
test("shows full context paths when expanded", () => { const home = homedir(); const cwd = path.join(home, "Development", "pi-mono"); const fakeThis = createShowLoadedResourcesThis({ quietStartup: false, toolOutputExpanded: true, cwd, contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: true, });
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/"); expect(output).toContain("[Context]"); expect(output).toContain("~/.pi/agent/AGENTS.md"); expect(output).toContain("~/Development/pi-mono/AGENTS.md"); expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md"); });
test("does not show verbose listing on quiet startup during reload", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: true, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { extensions: [{ path: "/tmp/ext/index.ts" }], force: false, showDiagnosticsWhenQuiet: true, });
expect(fakeThis.chatContainer.children).toHaveLength(0); });
test("still shows diagnostics on quiet startup when requested", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: true, skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }], skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }], });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: false, showDiagnosticsWhenQuiet: true, });
const output = renderAll(fakeThis.chatContainer); expect(output).toContain("[Skill warning]"); expect(output).not.toContain("[Skills]"); });
test("formats a multi-line skill warning without path noise", () => { const fakeThis = createShowLoadedResourcesThis({ quietStartup: true, skillDiagnostics: [ { type: "warning", message: "first line of the warning.\nsecond line with guidance.\n\n indented detail", }, ], useRealDiagnostics: true, });
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, { force: false, showDiagnosticsWhenQuiet: true, });
const output = normalizeRenderedOutput(fakeThis.chatContainer, 100); expect(output).toMatchInlineSnapshot(`"[Skill warning] first line of the warning. second line with guidance.
indented detail"`); expect(output).not.toContain("[Skill conflicts]"); });});
test("shows a refine loader from the /refine command message until its result row settles it", () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { ui: { requestRender: vi.fn() } as unknown as TUI, statusContainer, refineLoader: undefined, stopWorkingLoader: vi.fn(), syncWorkingLoader: vi.fn(), } as unknown as InteractiveMode; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); const prototype = InteractiveMode.prototype as unknown as { startRefineLoader(this: InteractiveMode): void; stopRefineLoader(this: InteractiveMode): void; };
prototype.startRefineLoader.call(fakeThis); expect(statusContainer.children.length).toBe(1);
prototype.stopRefineLoader.call(fakeThis); expect(statusContainer.children.length).toBe(0); expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).toHaveBeenCalled();});
test("syncWorkingLoader remounts a refine loader that a compaction cleared", () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { ui: { requestRender: vi.fn() } as unknown as TUI, statusContainer, refineLoader: undefined, autoCompactionLoader: undefined, retryLoader: undefined, stopWorkingLoader: vi.fn(), isAgentCompacting: () => false, } as unknown as InteractiveMode; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); const prototype = InteractiveMode.prototype as unknown as { startRefineLoader(this: InteractiveMode): void; syncWorkingLoader(this: InteractiveMode): void; };
prototype.startRefineLoader.call(fakeThis); statusContainer.clear(); expect(statusContainer.children.length).toBe(0);
prototype.syncWorkingLoader.call(fakeThis); expect(statusContainer.children.length).toBe(1);});
test("only the queued user /refine settlement stops its loader", async () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { ui: { requestRender: vi.fn() } as unknown as TUI, statusContainer, refineLoader: undefined, stopWorkingLoader: vi.fn(), syncWorkingLoader: vi.fn(), addMessageToChat: vi.fn(), showError: vi.fn(), isInitialized: true, footer: { invalidate: vi.fn() }, updateConnectionStateFromEvent: vi.fn(), prepareFeatureHintRun: vi.fn(), activityTracker: { handleEvent: vi.fn(), reset: vi.fn() }, updateWorkingLoaderMessage: vi.fn(), renderRecap: vi.fn(), } as unknown as InteractiveMode; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); const prototype = InteractiveMode.prototype as unknown as { handleEvent(this: InteractiveMode, event: unknown): Promise<void>; }; const command = { name: "refine", args: "", text: "/refine" } as const;
await prototype.handleEvent.call(fakeThis, { type: "message_start", message: createSessionSlashCommandMessage(command), }); expect(statusContainer.children.length).toBe(1);
// The waited-on agent/auto refinement settles first; the user /refine is still running. await prototype.handleEvent.call(fakeThis, { type: "refine_complete", result: {} }); expect(statusContainer.children.length).toBe(1); await prototype.handleEvent.call(fakeThis, { type: "refine_failed", error: "background failure" }); expect(statusContainer.children.length).toBe(1);
await prototype.handleEvent.call(fakeThis, { type: "message_start", message: createSessionSlashCommandResultMessage( "Refined continual harness state: 0 edits applied.", { command, success: true, severity: "info" }, false, ), }); expect(statusContainer.children.length).toBe(0);});
test("session teardown removes a running refine loader without remounting anything", () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { ui: { requestRender: vi.fn() } as unknown as TUI, statusContainer, refineLoader: undefined, stopWorkingLoader: vi.fn(), syncWorkingLoader: vi.fn(), } as unknown as InteractiveMode; Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); const prototype = InteractiveMode.prototype as unknown as { startRefineLoader(this: InteractiveMode): void; discardRefineLoader(this: InteractiveMode): void; };
prototype.startRefineLoader.call(fakeThis); const loader = (fakeThis as unknown as { refineLoader?: { stop(): void } }).refineLoader; const stopSpy = vi.spyOn(loader!, "stop");
prototype.discardRefineLoader.call(fakeThis); expect(stopSpy).toHaveBeenCalled(); expect(statusContainer.children).toHaveLength(0); expect((fakeThis as unknown as { refineLoader?: unknown }).refineLoader).toBeUndefined(); expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).not.toHaveBeenCalled();});