1- import { describe , expect , it , vi } from "vitest" ;
2- import { AgentsViewMode } from "../src/modes/agents-view/agents-view-mode.js" ;
1+ import { setKeybindings } from "@earendil-works/pi-tui" ;
2+ import { beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
3+ import { KeybindingsManager } from "../src/core/keybindings.js" ;
4+ import type { AgentConnectionSavedSessionInfo } from "../src/modes/agent-connection/types.js" ;
5+ import {
6+ AgentsViewMode ,
7+ type AgentsViewPersistentState ,
8+ runAgentsViewMode ,
9+ } from "../src/modes/agents-view/agents-view-mode.js" ;
10+ import { resolveAgentsViewLeftResult } from "../src/modes/agents-view/agents-view-state.js" ;
11+ import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js" ;
12+
13+ const modeMocks = vi . hoisted ( ( ) => ( {
14+ interactiveRun : vi . fn < ( ) => Promise < never > > ( ) ,
15+ teardownSessionUi : vi . fn ( async ( ) => undefined ) ,
16+ dispose : vi . fn ( async ( ) => undefined ) ,
17+ } ) ) ;
18+
19+ vi . mock ( "../src/config.js" , async ( importOriginal ) => {
20+ const actual = await importOriginal < typeof import ( "../src/config.js" ) > ( ) ;
21+ return { ...actual , appendRotatingLog : vi . fn ( ) } ;
22+ } ) ;
23+
24+ vi . mock ( "../src/modes/daemon/daemon-client.js" , ( ) => ( {
25+ DaemonClient : class {
26+ connect = vi . fn ( async ( ) => undefined ) ;
27+ close = vi . fn ( ) ;
28+ } ,
29+ getDaemonSocketCloseReason : vi . fn ( ) ,
30+ } ) ) ;
31+
32+ vi . mock ( "../src/modes/agent-connection/daemon-agent-connection.js" , ( ) => ( {
33+ DaemonAgentConnection : Object . assign ( function DaemonAgentConnection ( ) { } , {
34+ attach : vi . fn ( async ( ) => ( { dispose : modeMocks . dispose } ) ) ,
35+ } ) ,
36+ } ) ) ;
37+
38+ vi . mock ( "../src/modes/interactive/interactive-mode.js" , async ( importOriginal ) => {
39+ const actual = await importOriginal < typeof import ( "../src/modes/interactive/interactive-mode.js" ) > ( ) ;
40+ return {
41+ ...actual ,
42+ InteractiveMode : class {
43+ run = modeMocks . interactiveRun ;
44+ teardownSessionUi = modeMocks . teardownSessionUi ;
45+ } ,
46+ } ;
47+ } ) ;
48+
49+ function summary ( overrides : Partial < SessionSummary > = { } ) : SessionSummary {
50+ return {
51+ id : "scope-active" ,
52+ activeSessionId : "scope-active" ,
53+ lifecycle : "live" ,
54+ activity : "idle" ,
55+ isSessionActive : false ,
56+ sessionId : "scope-session" ,
57+ sessionFile : "/tmp/scope.jsonl" ,
58+ cwd : "/tmp" ,
59+ isStreaming : false ,
60+ isCompacting : false ,
61+ attachedClients : 0 ,
62+ messageCount : 1 ,
63+ sessionActions : { queuedCount : 0 , steering : [ ] , followUps : [ ] } ,
64+ ...overrides ,
65+ } ;
66+ }
67+
68+ function invoke ( method : string , self : object , ...args : unknown [ ] ) : unknown {
69+ const member = Reflect . get ( AgentsViewMode . prototype , method ) as ( ( ...a : unknown [ ] ) => unknown ) | undefined ;
70+ if ( typeof member !== "function" ) throw new Error ( `AgentsViewMode.${ method } no longer exists` ) ;
71+ return member . call ( self , ...args ) ;
72+ }
73+
74+ const settingsManager = {
75+ getTheme : ( ) => "dark" ,
76+ getShowHardwareCursor : ( ) => false ,
77+ getClearOnShrink : ( ) => false ,
78+ getEditorPaddingX : ( ) => 0 ,
79+ getAutocompleteMaxVisible : ( ) => 5 ,
80+ } ;
81+
82+ describe ( "AgentsViewMode" , ( ) => {
83+ beforeAll ( ( ) => setKeybindings ( new KeybindingsManager ( ) ) ) ;
84+ beforeEach ( ( ) => vi . clearAllMocks ( ) ) ;
385
4- describe ( "AgentsViewMode search selection" , ( ) => {
586 it ( "keeps the selection chosen by row rebuilding when the query changes" , ( ) => {
687 const self = {
788 editor : { getText : ( ) => "matching query" } ,
@@ -12,10 +93,168 @@ describe("AgentsViewMode search selection", () => {
1293 ui : { requestRender : vi . fn ( ) } ,
1394 } ;
1495
15- ( AgentsViewMode . prototype as unknown as { queryChanged ( this : typeof self ) : void } ) . queryChanged . call ( self ) ;
96+ invoke ( " queryChanged" , self ) ;
1697
1798 expect ( self . persistentState . query ) . toBe ( "matching query" ) ;
1899 expect ( self . rebuildRows ) . toHaveBeenCalledOnce ( ) ;
19100 expect ( self . selectedIndex ) . toBe ( 4 ) ;
20101 } ) ;
102+
103+ it ( "uses the opened session as the crash-path back target" , async ( ) => {
104+ const opened = summary ( { sessionName : "opened" } ) ;
105+ const previous = summary ( { id : "previous" , activeSessionId : "previous" , sessionId : "previous" } ) ;
106+ const runView = vi
107+ . spyOn ( AgentsViewMode . prototype , "run" )
108+ . mockResolvedValueOnce ( { type : "open" , summary : opened } )
109+ . mockImplementationOnce ( function ( this : AgentsViewMode ) {
110+ const state = ( this as unknown as { persistentState : AgentsViewPersistentState } ) . persistentState ;
111+ expect ( state . backSession ) . toMatchObject ( { sessionId : opened . sessionId } ) ;
112+ return Promise . resolve ( { type : "exit" } ) ;
113+ } ) ;
114+ modeMocks . interactiveRun . mockRejectedValueOnce ( new Error ( "post-attach crash" ) ) ;
115+
116+ await runAgentsViewMode ( {
117+ socketPath : "/tmp/fake-daemon.sock" ,
118+ config : { cwd : "/tmp" } as never ,
119+ initialSession : previous ,
120+ uiServices : {
121+ settingsManager : settingsManager as never ,
122+ modelRegistry : { } as never ,
123+ getInitialCwd : ( ) => "/tmp" ,
124+ getInitialSessionName : ( ) => undefined ,
125+ getThemes : ( ) => [ ] ,
126+ } ,
127+ } ) ;
128+
129+ expect ( modeMocks . teardownSessionUi ) . toHaveBeenCalledWith ( { preserveAltScreen : true } ) ;
130+ expect ( modeMocks . dispose ) . toHaveBeenCalledOnce ( ) ;
131+ runView . mockRestore ( ) ;
132+ } ) ;
133+
134+ it ( "does not discard scope while the saved-session refresh is in flight" , async ( ) => {
135+ let finishRefresh : ( ( value : { success : true ; data : { sessions : unknown [ ] } } ) => void ) | undefined ;
136+ const request = vi . fn (
137+ ( ) =>
138+ new Promise < { success : true ; data : { sessions : unknown [ ] } } > ( ( resolve ) => {
139+ finishRefresh = resolve ;
140+ } ) ,
141+ ) ;
142+ const scopeSummary = summary ( ) ;
143+ const persistentState : AgentsViewPersistentState = {
144+ scopeFrames : [ { scope : { sessionId : scopeSummary . sessionId , activeSessionId : scopeSummary . activeSessionId } } ] ,
145+ } ;
146+ const self : Record < string , unknown > = {
147+ options : { config : { cwd : "/tmp" } } ,
148+ persistentState,
149+ savedCatalogGeneration : 0 ,
150+ savedCatalogReady : true ,
151+ savedCatalogRefreshPending : false ,
152+ lastSuccessfulSavedSessions : [ ] ,
153+ savedSessions : [ ] ,
154+ requireClient : ( ) => ( { request } ) ,
155+ getSavedSessionCatalogContext : ( ) => ( { cwd : "/tmp" } ) ,
156+ reconcileCatalogs : vi . fn ( ) ,
157+ resolveMissingSelectionAnchor : vi . fn ( ) ,
158+ } ;
159+
160+ const refresh = invoke ( "refreshSavedSessions" , self ) as Promise < boolean > ;
161+ await vi . waitFor ( ( ) => expect ( request ) . toHaveBeenCalledOnce ( ) ) ;
162+ expect ( self . savedCatalogReady ) . toBe ( false ) ;
163+
164+ Object . assign ( self , {
165+ lastListedSummaries : [ ] ,
166+ heartbeats : [ ] ,
167+ inactiveAgentIdentities : new Set ( ) ,
168+ pendingDeleteAgent : undefined ,
169+ liveCatalogReady : true ,
170+ liveCatalogRefreshPending : false ,
171+ scopeKey : persistentState . scopeFrames ?. [ 0 ] ?. scope ,
172+ expandedSubagentParents : new Set ( ) ,
173+ programShownParents : new Set ( ) ,
174+ editor : { getText : ( ) => "" } ,
175+ getFilteredRecords : ( ) => Reflect . get ( self , "scopedRecords" ) ,
176+ applyPendingAncestorExpansion : vi . fn ( ) ,
177+ restoreSelection : vi . fn ( ) ,
178+ ui : { requestRender : vi . fn ( ) } ,
179+ setStatusMessage : vi . fn ( ) ,
180+ withPendingDeleteSession : ( sessions : SessionSummary [ ] ) => sessions ,
181+ } ) ;
182+ self . reconcileCatalogs = ( ) => invoke ( "reconcileCatalogs" , self ) ;
183+ invoke ( "reconcileCatalogs" , self ) ;
184+ expect ( persistentState . scopeFrames ) . toHaveLength ( 1 ) ;
185+
186+ const saved : AgentConnectionSavedSessionInfo = {
187+ path : "/tmp/scope.jsonl" ,
188+ id : "scope-session" ,
189+ cwd : "/tmp" ,
190+ created : new Date ( "2026-01-01T00:00:00Z" ) ,
191+ modified : new Date ( "2026-01-01T00:00:00Z" ) ,
192+ messageCount : 1 ,
193+ firstMessage : "scope" ,
194+ allMessagesText : "scope" ,
195+ } ;
196+ finishRefresh ?.( {
197+ success : true ,
198+ data : {
199+ sessions : [
200+ {
201+ ...saved ,
202+ created : saved . created . toISOString ( ) ,
203+ modified : saved . modified . toISOString ( ) ,
204+ } ,
205+ ] ,
206+ } ,
207+ } ) ;
208+ await expect ( refresh ) . resolves . toBe ( true ) ;
209+ expect ( persistentState . scopeFrames ) . toHaveLength ( 1 ) ;
210+ } ) ;
211+
212+ it ( "carries the resolved scope root across view remounts" , ( ) => {
213+ const root = summary ( { sessionName : "Scoped root" } ) ;
214+ const persistentState : AgentsViewPersistentState = {
215+ scopeFrames : [ { scope : { sessionId : root . sessionId , activeSessionId : root . activeSessionId } } ] ,
216+ } ;
217+ const self : Record < string , unknown > = {
218+ persistentState,
219+ lastListedSummaries : [ root ] ,
220+ savedSessions : [ ] ,
221+ heartbeats : [ ] ,
222+ inactiveAgentIdentities : new Set ( ) ,
223+ pendingDeleteAgent : undefined ,
224+ liveCatalogReady : true ,
225+ savedCatalogReady : true ,
226+ scopeKey : persistentState . scopeFrames ?. [ 0 ] ?. scope ,
227+ expandedSubagentParents : new Set ( ) ,
228+ programShownParents : new Set ( ) ,
229+ editor : { getText : ( ) => "" } ,
230+ getFilteredRecords : ( ) => Reflect . get ( self , "scopedRecords" ) ,
231+ applyPendingAncestorExpansion : vi . fn ( ) ,
232+ restoreSelection : vi . fn ( ) ,
233+ ui : { requestRender : vi . fn ( ) } ,
234+ setStatusMessage : vi . fn ( ) ,
235+ withPendingDeleteSession : ( sessions : SessionSummary [ ] ) => sessions ,
236+ } ;
237+ invoke ( "reconcileCatalogs" , self ) ;
238+ expect ( persistentState . scopeRootSummary ) . toMatchObject ( { sessionId : root . sessionId } ) ;
239+
240+ const remount = new AgentsViewMode (
241+ {
242+ config : { cwd : "/tmp" } as never ,
243+ uiServices : {
244+ settingsManager : settingsManager as never ,
245+ modelRegistry : { } as never ,
246+ getInitialCwd : ( ) => "/tmp" ,
247+ getInitialSessionName : ( ) => undefined ,
248+ getThemes : ( ) => [ ] ,
249+ } ,
250+ } ,
251+ persistentState ,
252+ ) as AgentsViewMode & Record < string , unknown > ;
253+ const remountedRoot = Reflect . get ( remount , "scopeRootSummary" ) as SessionSummary ;
254+ expect ( remountedRoot ) . toMatchObject ( { sessionName : "Scoped root" } ) ;
255+ expect ( resolveAgentsViewLeftResult ( remountedRoot ) ) . toMatchObject ( {
256+ type : "scope_back" ,
257+ selection : { sessionId : root . sessionId } ,
258+ } ) ;
259+ } ) ;
21260} ) ;
0 commit comments