Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 427 additions & 0 deletions complexity-reduction-plan.md

Large diffs are not rendered by default.

403 changes: 403 additions & 0 deletions pr-3889-review.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { RecentlyEditedTracker } from "../../continuedev/core/vscode-test-harnes
import type { GhostServiceSettings } from "@roo-code/types"
import { postprocessGhostSuggestion } from "./uselessSuggestionFilter"
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
import { RequestDebouncer } from "./RequestDebouncer"
import { RequestDeduplicator, type PendingRequest } from "./RequestDeduplicator"

const MAX_SUGGESTIONS_HISTORY = 20
const DEBOUNCE_DELAY_MS = 300
Expand Down Expand Up @@ -95,8 +97,9 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
private getSettings: () => GhostServiceSettings | null
private recentlyVisitedRangesService: RecentlyVisitedRangesService
private recentlyEditedTracker: RecentlyEditedTracker
private debounceTimer: NodeJS.Timeout | null = null
private ignoreController?: Promise<RooIgnoreController>
private debouncer: RequestDebouncer = new RequestDebouncer()
private deduplicator: RequestDeduplicator = new RequestDeduplicator()

constructor(
model: GhostModel,
Expand Down Expand Up @@ -192,10 +195,8 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
}

public dispose(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.debouncer.clear()
this.deduplicator.clear()
this.recentlyVisitedRangesService.dispose()
this.recentlyEditedTracker.dispose()
}
Expand Down Expand Up @@ -280,29 +281,118 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
}

private debouncedFetchAndCacheSuggestion(prompt: GhostPrompt, prefix: string, suffix: string): Promise<void> {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
return this.debouncer.debounce(() => this.fetchAndCacheSuggestion(prompt, prefix, suffix), DEBOUNCE_DELAY_MS)
}

/**
* Adjust a suggestion when user has typed ahead
*/
private adjustSuggestion(suggestion: string, originalPrefix: string, currentPrefix: string): string | null {
if (!currentPrefix.startsWith(originalPrefix)) {
return null // Can't adjust
}

return new Promise<void>((resolve) => {
this.debounceTimer = setTimeout(async () => {
this.debounceTimer = null
await this.fetchAndCacheSuggestion(prompt, prefix, suffix)
resolve()
}, DEBOUNCE_DELAY_MS)
})
const typedAhead = currentPrefix.slice(originalPrefix.length)
if (!suggestion.startsWith(typedAhead)) {
return null // Suggestion doesn't match
}

return suggestion.slice(typedAhead.length)
}

/**
* Handle errors from aborted requests
*/
private isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted"))
}

private async fetchAndCacheSuggestion(prompt: GhostPrompt, prefix: string, suffix: string): Promise<void> {
// Check if we can reuse an existing pending request
const reusable = this.deduplicator.findReusable(prefix, suffix)
if (reusable) {
try {
const result = await reusable.promise

// Check if request was aborted while waiting
if (reusable.abortController.signal.aborted) {
return
}

// If user typed ahead, adjust the suggestion
if (prefix !== reusable.prefix) {
const adjusted = this.adjustSuggestion(result.suggestion.text, reusable.prefix, prefix)
if (adjusted !== null) {
this.updateSuggestions({ text: adjusted, prefix, suffix })
return
}
}

// Use the result as-is if no adjustment needed
this.updateSuggestions(result.suggestion)
return
} catch (error) {
if (this.isAbortError(error)) {
return
}
console.warn("Reused request failed, creating new request:", error)
}
}

// Cancel any pending requests that are now obsolete
this.deduplicator.cancelObsolete(prefix, suffix)

// Create new request
const abortController = new AbortController()
const promise = this.executeRequest(prompt, prefix, suffix, abortController)

// Store the pending request
const request: PendingRequest = {
prefix,
suffix,
promise,
abortController,
}
this.deduplicator.add(prefix, suffix, request)

try {
// Curry processSuggestion with prefix, suffix, and model - only text needs to be provided
await promise
} catch (error) {
if (this.isAbortError(error)) {
return
}
console.error("Error getting inline completion from LLM:", error)
}
}

/**
* Execute the actual LLM request
*/
private async executeRequest(
prompt: GhostPrompt,
prefix: string,
suffix: string,
abortController: AbortController,
): Promise<LLMRetrievalResult> {
try {
// Check if already aborted before starting
if (abortController.signal.aborted) {
throw new Error("Request aborted before starting")
}

// Curry processSuggestion with prefix, suffix, and model
const curriedProcessSuggestion = (text: string) => this.processSuggestion(text, prefix, suffix, this.model)

const result =
prompt.strategy === "fim"
? await this.fimPromptBuilder.getFromFIM(this.model, prompt, curriedProcessSuggestion)
: await this.holeFiller.getFromChat(this.model, prompt, curriedProcessSuggestion)

// Check if aborted after completion
if (abortController.signal.aborted) {
throw new Error("Request aborted after completion")
}

if (this.costTrackingCallback && result.cost > 0) {
this.costTrackingCallback(
result.cost,
Expand All @@ -315,8 +405,11 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte

// Always update suggestions, even if text is empty (for caching)
this.updateSuggestions(result.suggestion)
} catch (error) {
console.error("Error getting inline completion from LLM:", error)

return result
} finally {
// Clean up from pending requests map
this.deduplicator.remove(prefix, suffix)
}
}
}
101 changes: 101 additions & 0 deletions src/services/ghost/classic-auto-complete/RequestDebouncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Handles debouncing of autocomplete requests with intelligent flushing
*/
export class RequestDebouncer {
private timer: NodeJS.Timeout | null = null
private pendingResolvers: Array<() => void> = []
private lastRequest: { execute: () => Promise<void> } | null = null

/**
* Debounce a request execution
* @param execute - Function to execute after debounce delay
* @param delay - Delay in milliseconds
* @param shouldFlush - Optional function to determine if pending request should flush immediately
* @returns Promise that resolves when request completes
*/
debounce(
execute: () => Promise<void>,
delay: number,
shouldFlush?: (lastRequest: { execute: () => Promise<void> } | null) => boolean,
): Promise<void> {
// Check if we should flush the pending request immediately
if (this.timer && shouldFlush?.(this.lastRequest)) {
this.flush()
} else if (this.timer) {
// Just clear the timer to restart debounce
clearTimeout(this.timer)
}

// Store the current request
this.lastRequest = { execute }

return new Promise<void>((resolve) => {
// Add this resolver to the list
this.pendingResolvers.push(resolve)

this.timer = setTimeout(async () => {
this.timer = null
// Execute the last request that was set
if (this.lastRequest) {
try {
await this.lastRequest.execute()
} catch (error) {
// Silently catch errors - they should be handled by the execute function
console.error("Error in debounced request:", error)
}
this.lastRequest = null
}

// Resolve all pending promises
const resolvers = this.pendingResolvers.splice(0)
resolvers.forEach((r) => r())
}, delay)
})
}

/**
* Flush any pending debounced request immediately
*/
flush(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}

// Execute the pending request if it exists
if (this.lastRequest) {
const request = this.lastRequest
this.lastRequest = null
const resolvers = this.pendingResolvers.splice(0)

request
.execute()
.catch((error) => {
// Silently catch errors
console.error("Error in flushed request:", error)
})
.then(() => {
resolvers.forEach((r) => r())
})
}
}

/**
* Clear all pending requests without executing them
*/
clear(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
this.pendingResolvers = []
this.lastRequest = null
}

/**
* Check if there's a pending debounced request
*/
hasPending(): boolean {
return this.timer !== null
}
}
106 changes: 106 additions & 0 deletions src/services/ghost/classic-auto-complete/RequestDeduplicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { LLMRetrievalResult } from "./GhostInlineCompletionProvider"

export interface PendingRequest {
prefix: string
suffix: string
promise: Promise<LLMRetrievalResult>
abortController: AbortController
}

/**
* Manages deduplication and reuse of pending autocomplete requests
*/
export class RequestDeduplicator {
private pendingRequests = new Map<string, PendingRequest>()

/**
* Create a cache key for exact match lookups
*/
private getCacheKey(prefix: string, suffix: string): string {
return `${prefix}|||${suffix}`
}

/**
* Check if a request can be reused for the given prefix/suffix
*/
private canReuse(request: PendingRequest, prefix: string, suffix: string): boolean {
// Must have same suffix
if (request.suffix !== suffix) {
return false
}

// Current prefix must start with the request's prefix (user typed ahead)
return prefix.startsWith(request.prefix)
}

/**
* Find a reusable pending request for the given prefix/suffix
* @returns The reusable request, or null if none found
*/
findReusable(prefix: string, suffix: string): PendingRequest | null {
// Check for exact match first
const cacheKey = this.getCacheKey(prefix, suffix)
const exactMatch = this.pendingRequests.get(cacheKey)
if (exactMatch) {
return exactMatch
}

// Check if we can reuse a request with a shorter prefix (user typed ahead)
for (const request of this.pendingRequests.values()) {
if (this.canReuse(request, prefix, suffix)) {
return request
}
}

return null
}

/**
* Add a new pending request
*/
add(prefix: string, suffix: string, request: PendingRequest): void {
const cacheKey = this.getCacheKey(prefix, suffix)
this.pendingRequests.set(cacheKey, request)
}

/**
* Remove a pending request
*/
remove(prefix: string, suffix: string): void {
const cacheKey = this.getCacheKey(prefix, suffix)
this.pendingRequests.delete(cacheKey)
}

/**
* Cancel and remove all pending requests that cannot be reused for the given prefix/suffix
*/
cancelObsolete(prefix: string, suffix: string): void {
for (const [key, request] of this.pendingRequests.entries()) {
// Cancel if different suffix or if prefix has diverged
if (
request.suffix !== suffix ||
(!prefix.startsWith(request.prefix) && !request.prefix.startsWith(prefix))
) {
request.abortController.abort()
this.pendingRequests.delete(key)
}
}
}

/**
* Cancel and clear all pending requests
*/
clear(): void {
for (const request of this.pendingRequests.values()) {
request.abortController.abort()
}
this.pendingRequests.clear()
}

/**
* Get the number of pending requests
*/
size(): number {
return this.pendingRequests.size
}
}
Loading
Loading