Skip to content

Commit 805cb39

Browse files
authored
feat(amazonq): Prefetch next inline recommendation (#5290)
This PR introduces the ability for users to see the next recommendation immediately after accepting the current one, provided there is a subsequent recommendation available. The key enhancements include: Automatic Prefetching: Introduced a function that calls the CodeWhisperer API to fetch the next recommendation while the current one is being reviewed. Session Promotion Added functionality to display the next recommendation instantly upon accepting the current recommendation (if there is one) Helper Utilities: Implemented helper functions to calculate the necessary file information for the next request and to send telemetry events related to the subsequent recommendation.
1 parent cf48b0c commit 805cb39

File tree

9 files changed

+203
-25
lines changed

9 files changed

+203
-25
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type" : "feature",
3+
"description" : "Inline suggestions: Pre-fetch recommendations to reduce suggestion latency."
4+
}

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ interface CodeWhispererClientAdaptor : Disposable {
7676
firstRequest: GenerateCompletionsRequest,
7777
): Sequence<GenerateCompletionsResponse>
7878

79+
fun generateCompletions(
80+
firstRequest: GenerateCompletionsRequest,
81+
): GenerateCompletionsResponse
82+
7983
fun createUploadUrl(
8084
request: CreateUploadUrlRequest,
8185
): CreateUploadUrlResponse
@@ -322,6 +326,8 @@ open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeW
322326
yield(response)
323327
} while (!nextToken.isNullOrEmpty())
324328
}
329+
override fun generateCompletions(firstRequest: GenerateCompletionsRequest): GenerateCompletionsResponse =
330+
bearerClient().generateCompletions(firstRequest)
325331

326332
override fun createUploadUrl(request: CreateUploadUrlRequest): CreateUploadUrlResponse =
327333
bearerClient().createUploadUrl(request)

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupListener.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.intellij.openapi.ui.popup.JBPopupListener
77
import com.intellij.openapi.ui.popup.LightweightWindowEvent
88
import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext
99
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus
10+
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService
1011
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererServiceNew
1112
import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService
1213
import java.time.Duration
@@ -27,7 +28,8 @@ class CodeWhispererPopupListener(private val states: InvocationContext) : JBPopu
2728
recommendationContext,
2829
CodeWhispererPopupManager.getInstance().sessionContext,
2930
event.isOk,
30-
CodeWhispererInvocationStatus.getInstance().popupStartTimestamp?.let { Duration.between(it, Instant.now()) }
31+
CodeWhispererInvocationStatus.getInstance().popupStartTimestamp?.let { Duration.between(it, Instant.now()) },
32+
CodeWhispererService.getInstance().getNextInvocationContext()
3133
)
3234

3335
CodeWhispererInvocationStatus.getInstance().setDisplaySessionActive(false)

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupManager.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.Co
6969
import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererScrollListener
7070
import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.addIntelliSenseAcceptListener
7171
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus
72+
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService
7273
import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService
7374
import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager
7475
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_DIM_HEX
@@ -460,6 +461,9 @@ class CodeWhispererPopupManager {
460461
CodeWhispererEditorManager.getInstance().updateEditorWithRecommendation(states, sessionContext)
461462
}
462463
closePopup(states.popup)
464+
if (sessionContext.selectedIndex == 0) {
465+
CodeWhispererService.getInstance().promoteNextInvocationIfAvailable()
466+
}
463467
}
464468
}
465469
)

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.intellij.openapi.editor.Editor
1616
import com.intellij.openapi.editor.VisualPosition
1717
import com.intellij.openapi.project.Project
1818
import com.intellij.openapi.ui.popup.JBPopup
19+
import com.intellij.openapi.ui.popup.JBPopupFactory
1920
import com.intellij.openapi.util.Disposer
2021
import com.intellij.psi.PsiDocumentManager
2122
import com.intellij.psi.PsiFile
@@ -24,6 +25,7 @@ import com.intellij.util.messages.Topic
2425
import kotlinx.coroutines.CoroutineScope
2526
import kotlinx.coroutines.Deferred
2627
import kotlinx.coroutines.Job
28+
import kotlinx.coroutines.SupervisorJob
2729
import kotlinx.coroutines.async
2830
import kotlinx.coroutines.delay
2931
import kotlinx.coroutines.isActive
@@ -98,6 +100,7 @@ import java.util.concurrent.TimeUnit
98100
class CodeWhispererService(private val cs: CoroutineScope) : Disposable {
99101
private val codeInsightSettingsFacade = CodeInsightsSettingsFacade()
100102
private var refreshFailure: Int = 0
103+
private var nextInvocationContext: InvocationContext? = null
101104

102105
init {
103106
Disposer.register(this, codeInsightSettingsFacade)
@@ -209,7 +212,118 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable {
209212
invokeCodeWhispererInBackground(requestContext)
210213
}
211214

212-
internal suspend fun invokeCodeWhispererInBackground(requestContext: RequestContext): Job {
215+
internal suspend fun invokeCodeWhispererInBackground(
216+
requestContext: RequestContext,
217+
currStates: InvocationContext? = null,
218+
): Job {
219+
// current states != null means that it's prefetch
220+
if (currStates != null) {
221+
val firstValidRecommendation = currStates.recommendationContext.details
222+
.firstOrNull {
223+
!it.isDiscarded && it.recommendation.content().isNotEmpty()
224+
} ?: return SupervisorJob().apply { complete() }
225+
val job = cs.launch(getCoroutineBgContext()) {
226+
val latencyContext = LatencyContext().apply {
227+
codewhispererPreprocessingStart = System.nanoTime()
228+
codewhispererEndToEndStart = System.nanoTime()
229+
}
230+
231+
val nextCaretPosition = CaretPosition(
232+
line = requestContext.caretPosition.line + firstValidRecommendation.recommendation.content().count { it == '\n' },
233+
offset = requestContext.caretPosition.offset + firstValidRecommendation.recommendation.content().length
234+
)
235+
236+
val nextFileContextInfo = requestContext.fileContextInfo.copy(
237+
caretContext = requestContext.fileContextInfo.caretContext.copy(
238+
leftFileContext = requestContext.fileContextInfo.caretContext.leftFileContext + firstValidRecommendation.recommendation.content()
239+
)
240+
)
241+
242+
val nextRequestContext = requestContext.copy(
243+
caretPosition = nextCaretPosition,
244+
fileContextInfo = nextFileContextInfo,
245+
latencyContext = latencyContext
246+
)
247+
val newVisualPosition = withContext(EDT) {
248+
runReadAction {
249+
nextRequestContext.editor.offsetToVisualPosition(nextRequestContext.caretPosition.offset)
250+
}
251+
}
252+
try {
253+
val nextResponse = CodeWhispererClientAdaptor
254+
.getInstance(nextRequestContext.project)
255+
.generateCompletions(
256+
buildCodeWhispererRequest(
257+
nextRequestContext.fileContextInfo,
258+
nextRequestContext.awaitSupplementalContext(),
259+
nextRequestContext.customizationArn
260+
)
261+
)
262+
val startTime = System.nanoTime()
263+
nextRequestContext.latencyContext.codewhispererPreprocessingEnd = System.nanoTime()
264+
nextRequestContext.latencyContext.paginationAllCompletionsStart = System.nanoTime()
265+
CodeWhispererInvocationStatus.getInstance().setInvocationStart()
266+
nextResponse.let {
267+
val endTime = System.nanoTime()
268+
val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble()
269+
val requestId = nextResponse.responseMetadata().requestId()
270+
val sessionId = nextResponse.sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0]
271+
272+
nextRequestContext.latencyContext.apply {
273+
codewhispererPostprocessingStart = System.nanoTime()
274+
paginationFirstCompletionTime = (endTime - codewhispererEndToEndStart).toDouble()
275+
firstRequestId = requestId
276+
}
277+
278+
CodeWhispererInvocationStatus.getInstance().setInvocationSessionId(sessionId)
279+
280+
val nextResponseContext = ResponseContext(sessionId)
281+
CodeWhispererTelemetryService.getInstance().sendServiceInvocationEvent(
282+
nextResponse.responseMetadata().requestId(),
283+
nextRequestContext,
284+
nextResponseContext,
285+
nextResponse.completions().size,
286+
invocationSuccess = true,
287+
latency,
288+
null
289+
)
290+
val validatedResponse = validateResponse(it)
291+
val detailContexts = withContext(EDT) {
292+
runReadAction {
293+
CodeWhispererRecommendationManager.getInstance().buildDetailContext(
294+
nextRequestContext,
295+
"",
296+
validatedResponse.completions(),
297+
validatedResponse.responseMetadata().requestId()
298+
)
299+
}
300+
}
301+
val nextRecommendationContext = RecommendationContext(detailContexts, "", "", newVisualPosition)
302+
val newPopup = withContext(EDT) {
303+
JBPopupFactory.getInstance().createMessage("Dummy popup")
304+
}
305+
306+
// send userDecision and trigger decision when next recommendation haven't been seen
307+
if (currStates.popup.isDisposed) {
308+
CodeWhispererTelemetryService.getInstance().sendUserDecisionEventForAll(
309+
nextRequestContext,
310+
nextResponseContext,
311+
nextRecommendationContext,
312+
SessionContext(),
313+
false
314+
)
315+
} else {
316+
nextInvocationContext = InvocationContext(nextRequestContext, nextResponseContext, nextRecommendationContext, newPopup)
317+
}
318+
LOG.debug { "Prefetched next invocation stored in nextInvocationContext" }
319+
}
320+
} catch (ex: Exception) {
321+
LOG.warn { "Failed to prefetch next codewhisperer invocation: ${ex.message}" }
322+
}
323+
}
324+
return job
325+
}
326+
213327
val popup = withContext(EDT) {
214328
CodeWhispererPopupManager.getInstance().initPopup().also {
215329
Disposer.register(it) { CodeWhispererInvocationStatus.getInstance().finishInvocation() }
@@ -491,6 +605,9 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable {
491605
CodeWhispererPopupManager.getInstance().cancelPopup(popup)
492606
return null
493607
}
608+
cs.launch(getCoroutineBgContext()) {
609+
invokeCodeWhispererInBackground(requestContext, nextStates)
610+
}
494611
} else {
495612
// subsequent responses
496613
nextStates = updateStates(currStates, response)
@@ -616,6 +733,34 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable {
616733
CodeWhispererPopupManager.getInstance().changeStates(states, 0, recommendationAdded)
617734
}
618735

736+
fun promoteNextInvocationIfAvailable() {
737+
val nextStates = nextInvocationContext ?: run {
738+
LOG.debug { "No nextInvocationContext found, nothing to promote." }
739+
return
740+
}
741+
nextInvocationContext?.popup?.let { Disposer.dispose(it) }
742+
nextInvocationContext = null
743+
744+
cs.launch {
745+
val newPopup = CodeWhispererPopupManager.getInstance().initPopup()
746+
val updatedNextStates = nextStates.copy(popup = newPopup).also {
747+
addPopupChildDisposables(it.requestContext.project, it.requestContext.editor, it.popup)
748+
Disposer.register(newPopup, it)
749+
}
750+
CodeWhispererPopupManager.getInstance().initPopupListener(updatedNextStates)
751+
withContext(EDT) {
752+
CodeWhispererPopupManager.getInstance().changeStates(
753+
updatedNextStates,
754+
0,
755+
recommendationAdded = false
756+
)
757+
}
758+
invokeCodeWhispererInBackground(updatedNextStates.requestContext, updatedNextStates)
759+
}
760+
761+
LOG.debug { "Promoted nextInvocationContext to current session and displayed next recommendation." }
762+
}
763+
619764
private fun sendDiscardedUserDecisionEventForAll(
620765
requestContext: RequestContext,
621766
responseContext: ResponseContext,
@@ -782,6 +927,8 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable {
782927

783928
override fun dispose() {}
784929

930+
fun getNextInvocationContext(): InvocationContext? = nextInvocationContext
931+
785932
companion object {
786933
private val LOG = getLogger<CodeWhispererService>()
787934
private const val MAX_REFRESH_ATTEMPT = 3

plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ class CodeWhispererTelemetryService {
459459
sessionContext: SessionContext,
460460
hasUserAccepted: Boolean,
461461
popupShownTime: Duration? = null,
462+
nextInvocationContext: InvocationContext? = null,
462463
) {
463464
val detailContexts = recommendationContext.details
464465
val decisions = mutableListOf<CodewhispererSuggestionState>()
@@ -506,6 +507,19 @@ class CodeWhispererTelemetryService {
506507
previousUserTriggerDecisions.add(this)
507508
// we need this as well because AutotriggerService will reset the queue periodically
508509
CodeWhispererAutoTriggerService.getInstance().addPreviousDecision(this)
510+
// send possible next session event if current action is reject and next popup haven't shown up
511+
if (CodewhispererSuggestionState.from(this.toString()) == CodewhispererSuggestionState.Reject) {
512+
nextInvocationContext?.let {
513+
sendUserDecisionEventForAll(
514+
it.requestContext,
515+
it.responseContext,
516+
it.recommendationContext,
517+
SessionContext(),
518+
false,
519+
nextInvocationContext = null
520+
)
521+
}
522+
}
509523
}
510524
}
511525

plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package software.aws.toolkits.jetbrains.services.codewhisperer
55

66
import com.intellij.analysis.problemsView.toolWindow.ProblemsView
7-
import com.intellij.openapi.application.ApplicationManager
87
import com.intellij.openapi.components.service
98
import com.intellij.openapi.wm.RegisterToolWindowTask
109
import com.intellij.openapi.wm.ToolWindow
@@ -20,14 +19,12 @@ import org.junit.Ignore
2019
import org.junit.Test
2120
import org.mockito.kotlin.any
2221
import org.mockito.kotlin.never
23-
import org.mockito.kotlin.spy
2422
import org.mockito.kotlin.verify
2523
import org.mockito.kotlin.whenever
2624
import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl
2725
import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType
2826
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreActionState
2927
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled
30-
import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService
3128
import software.aws.toolkits.jetbrains.services.codewhisperer.status.CodeWhispererStatusBarWidgetFactory
3229
import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceToolWindowFactory
3330
import software.aws.toolkits.jetbrains.settings.CodeWhispererConfiguration
@@ -37,14 +34,11 @@ import kotlin.test.fail
3734

3835
class CodeWhispererSettingsTest : CodeWhispererTestBase() {
3936

40-
private lateinit var codewhispererServiceSpy: CodeWhispererService
4137
private lateinit var toolWindowHeadlessManager: ToolWindowHeadlessManagerImpl
4238

4339
@Before
4440
override fun setUp() {
4541
super.setUp()
46-
codewhispererServiceSpy = spy(codewhispererService)
47-
ApplicationManager.getApplication().replaceService(CodeWhispererService::class.java, codewhispererServiceSpy, disposableRule.disposable)
4842

4943
// Create a mock ToolWindowManager with working implementation of setAvailable() and isAvailable()
5044
toolWindowHeadlessManager = object : ToolWindowHeadlessManagerImpl(projectRule.project) {
@@ -86,7 +80,7 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() {
8680
whenever(stateManager.checkActiveCodeWhispererConnectionType(projectRule.project)).thenReturn(CodeWhispererLoginType.Logout)
8781
assertThat(isCodeWhispererEnabled(projectRule.project)).isFalse
8882
invokeCodeWhispererService()
89-
verify(codewhispererServiceSpy, never()).showRecommendationsInPopup(any(), any(), any())
83+
verify(codewhispererService, never()).showRecommendationsInPopup(any(), any(), any())
9084
}
9185

9286
@Test
@@ -95,7 +89,7 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() {
9589
assertThat(stateManager.isAutoEnabled()).isFalse
9690
runInEdtAndWait {
9791
projectRule.fixture.type(':')
98-
verify(codewhispererServiceSpy, never()).showRecommendationsInPopup(any(), any(), any())
92+
verify(codewhispererService, never()).showRecommendationsInPopup(any(), any(), any())
9993
}
10094
}
10195

0 commit comments

Comments
 (0)