@@ -40,7 +40,7 @@ import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
4040import { IChatCustomAgentsService } from '../../common/chatCustomAgentsService' ;
4141import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService' ;
4242import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService' ;
43- import { IChatSessionWorktreeService , type ChatSessionWorktreeFile , type ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService' ;
43+ import { IChatSessionWorktreeService , type ChatSessionWorktreeFile , type ChatSessionWorktreeProperties , type ChatSessionWorktreePropertiesV2 } from '../../common/chatSessionWorktreeService' ;
4444import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore' ;
4545import { getWorkingDirectory , IWorkspaceInfo } from '../../common/workspaceInfo' ;
4646import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService' ;
@@ -1971,4 +1971,141 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
19711971 expect ( agent ?. tools ) . toBeNull ( ) ;
19721972 } ) ;
19731973 } ) ;
1974+
1975+ describe ( 'PR detection with retry' , ( ) => {
1976+ let octoKitService : IOctoKitService ;
1977+
1978+ const v2WorktreeProperties : ChatSessionWorktreePropertiesV2 = {
1979+ version : 2 ,
1980+ baseCommit : 'abc123' ,
1981+ branchName : 'copilot/test-branch' ,
1982+ baseBranchName : 'main' ,
1983+ repositoryPath : `${ sep } repo` ,
1984+ worktreePath : `${ sep } worktree` ,
1985+ } ;
1986+
1987+ const repoContext : RepoContext = {
1988+ rootUri : Uri . file ( `${ sep } repo` ) ,
1989+ kind : 'repository' ,
1990+ remotes : [ 'origin' ] ,
1991+ remoteFetchUrls : [ 'https://github.com/testowner/testrepo.git' ] ,
1992+ } as unknown as RepoContext ;
1993+
1994+ beforeEach ( ( ) => {
1995+ vi . useFakeTimers ( ) ;
1996+ octoKitService = {
1997+ findPullRequestByHeadBranch : vi . fn ( async ( ) => undefined ) ,
1998+ } as unknown as IOctoKitService ;
1999+
2000+ // Set up folder & git repo so session creation succeeds with worktree isolation
2001+ folderRepositoryManager . setUntitledSessionFolder ( 'untitled:pr-test' , Uri . file ( `${ sep } repo` ) ) ;
2002+ git . setRepo ( repoContext ) ;
2003+ ( worktree . createWorktree as unknown as ReturnType < typeof vi . fn > ) . mockResolvedValue ( v2WorktreeProperties ) ;
2004+ // After session creation, getWorktreeProperties returns v2 for any session
2005+ ( worktree . getWorktreeProperties as unknown as ReturnType < typeof vi . fn > ) . mockResolvedValue ( v2WorktreeProperties ) ;
2006+ TestCopilotCLISession . statusOverride = vscode . ChatSessionStatus . Completed ;
2007+
2008+ // Recreate participant with the controllable octoKitService
2009+ participant = new CopilotCLIChatSessionParticipant (
2010+ contentProvider ,
2011+ promptResolver ,
2012+ itemProvider ,
2013+ cloudProvider ,
2014+ repositoryTracker ,
2015+ git ,
2016+ models as unknown as ICopilotCLIModels ,
2017+ new NullCopilotCLIAgents ( ) ,
2018+ sessionService ,
2019+ worktree ,
2020+ worktreeCheckpointService ,
2021+ workspaceFolderService ,
2022+ telemetry ,
2023+ logService ,
2024+ new PromptsServiceImpl ( new NullWorkspaceService ( ) ) ,
2025+ new ( mock < IChatDelegationSummaryService > ( ) ) ( ) ,
2026+ folderRepositoryManager ,
2027+ configurationService ,
2028+ sdk ,
2029+ new MockChatSessionMetadataStore ( ) ,
2030+ customSessionTitleService ,
2031+ octoKitService ,
2032+ ) ;
2033+ } ) ;
2034+
2035+ afterEach ( ( ) => {
2036+ vi . useRealTimers ( ) ;
2037+ } ) ;
2038+
2039+ it ( 'retries PR detection with exponential backoff and succeeds on second attempt' , async ( ) => {
2040+ const findPr = octoKitService . findPullRequestByHeadBranch as ReturnType < typeof vi . fn > ;
2041+ findPr
2042+ . mockResolvedValueOnce ( undefined ) // attempt 1: not found
2043+ . mockResolvedValueOnce ( { url : 'https://github.com/testowner/testrepo/pull/42' } ) ; // attempt 2: found
2044+
2045+ const request = new TestChatRequest ( 'Create a PR' ) ;
2046+ const context = createChatContext ( 'untitled:pr-test' , true ) ;
2047+ const stream = new MockChatResponseStream ( ) ;
2048+ const token = disposables . add ( new CancellationTokenSource ( ) ) . token ;
2049+
2050+ const handlerPromise = participant . createHandler ( ) ( request , context , stream , token ) ;
2051+ await vi . runAllTimersAsync ( ) ;
2052+ await handlerPromise ;
2053+
2054+ // Should have been called twice (after 2s delay, then after 4s delay)
2055+ expect ( findPr ) . toHaveBeenCalledTimes ( 2 ) ;
2056+ // Should have persisted the PR URL
2057+ expect ( worktree . setWorktreeProperties ) . toHaveBeenCalledWith (
2058+ expect . any ( String ) ,
2059+ expect . objectContaining ( { pullRequestUrl : 'https://github.com/testowner/testrepo/pull/42' } )
2060+ ) ;
2061+ } ) ;
2062+
2063+ it ( 'stops retrying once all attempts are exhausted' , async ( ) => {
2064+ const findPr = octoKitService . findPullRequestByHeadBranch as ReturnType < typeof vi . fn > ;
2065+ findPr . mockResolvedValue ( undefined ) ; // always returns not found
2066+
2067+ const request = new TestChatRequest ( 'Create something' ) ;
2068+ const context = createChatContext ( 'untitled:pr-test' , true ) ;
2069+ const stream = new MockChatResponseStream ( ) ;
2070+ const token = disposables . add ( new CancellationTokenSource ( ) ) . token ;
2071+
2072+ const handlerPromise = participant . createHandler ( ) ( request , context , stream , token ) ;
2073+ await vi . runAllTimersAsync ( ) ;
2074+ await handlerPromise ;
2075+
2076+ // 3 attempts total (after 2s, 4s, and 8s delays)
2077+ expect ( findPr ) . toHaveBeenCalledTimes ( 3 ) ;
2078+ // Should NOT have persisted any PR URL since all attempts failed
2079+ const setPropsCallsWithPrUrl = ( worktree . setWorktreeProperties as ReturnType < typeof vi . fn > ) . mock . calls
2080+ . filter ( ( args : unknown [ ] ) => ( args [ 1 ] as { pullRequestUrl ?: string } ) ?. pullRequestUrl !== undefined ) ;
2081+ expect ( setPropsCallsWithPrUrl ) . toHaveLength ( 0 ) ;
2082+ } ) ;
2083+
2084+ it ( 'skips retry when session already has createdPullRequestUrl' , async ( ) => {
2085+ const findPr = octoKitService . findPullRequestByHeadBranch as ReturnType < typeof vi . fn > ;
2086+
2087+ // Make the session report a PR URL directly
2088+ TestCopilotCLISession . handleRequestHook = vi . fn ( async ( ) => {
2089+ const session = cliSessions [ cliSessions . length - 1 ] ;
2090+ ( session as any ) . _createdPullRequestUrl = 'https://github.com/testowner/testrepo/pull/99' ;
2091+ } ) ;
2092+
2093+ const request = new TestChatRequest ( 'Create a PR via MCP' ) ;
2094+ const context = createChatContext ( 'untitled:pr-test' , true ) ;
2095+ const stream = new MockChatResponseStream ( ) ;
2096+ const token = disposables . add ( new CancellationTokenSource ( ) ) . token ;
2097+
2098+ const handlerPromise = participant . createHandler ( ) ( request , context , stream , token ) ;
2099+ await vi . runAllTimersAsync ( ) ;
2100+ await handlerPromise ;
2101+
2102+ // Should NOT have called the GitHub API since session had the URL
2103+ expect ( findPr ) . not . toHaveBeenCalled ( ) ;
2104+ // Should have persisted the session's PR URL
2105+ expect ( worktree . setWorktreeProperties ) . toHaveBeenCalledWith (
2106+ expect . any ( String ) ,
2107+ expect . objectContaining ( { pullRequestUrl : 'https://github.com/testowner/testrepo/pull/99' } )
2108+ ) ;
2109+ } ) ;
2110+ } ) ;
19742111} ) ;
0 commit comments