summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-19 21:57:38 +0300
committerPaul Buetow <paul@buetow.org>2025-08-19 21:57:38 +0300
commit9f59e7acd647f9adc0903e9c9655c04495f13a53 (patch)
treeedef52111c7fa87227b3d3d14b3716f458a8e5ed
parentef188388102b0377ed506b8767536233575965bb (diff)
lsp: replace time throttle with in-flight guard; improve short-prefix heuristic\n\n- Prevent overlapping LLM requests via llmBusy guard\n- Remove time-based throttle and option plumbing\n- Short-prefix heuristic now skips over trailing whitespace and clamps index\n- Add tests for busy guard and trailing-space allowance
-rw-r--r--internal/hexailsp/run.go2
-rw-r--r--internal/lsp/completion_throttle_test.go34
-rw-r--r--internal/lsp/handlers.go52
-rw-r--r--internal/lsp/server.go30
4 files changed, 78 insertions, 40 deletions
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go
index dd12600..8721a60 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -107,7 +107,5 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls
CodingTemperature: cfg.CodingTemperature,
Client: client,
TriggerCharacters: cfg.TriggerCharacters,
- // Optional; when zero, server uses a sensible default
- MinCompletionIntervalMs: 0,
}
}
diff --git a/internal/lsp/completion_throttle_test.go b/internal/lsp/completion_throttle_test.go
index 2de8edb..f986562 100644
--- a/internal/lsp/completion_throttle_test.go
+++ b/internal/lsp/completion_throttle_test.go
@@ -5,7 +5,6 @@ import (
"context"
"log"
"testing"
- "time"
"hexai/internal/llm"
)
@@ -35,24 +34,22 @@ func TestDefaultTriggerChars_DoesNotIncludeSemicolonOrQuestion(t *testing.T) {
}
}
-func TestTryLLMCompletion_ThrottleSkipsRapidCalls(t *testing.T) {
- // Build server with long min interval and set last completion to now
+func TestTryLLMCompletion_BusySkipsConcurrent(t *testing.T) {
s := &Server{ maxTokens: 32 }
- s.minCompletionInterval = time.Hour
- s.lastLLMCompletion = time.Now()
fake := &countingLLM{}
s.llmClient = fake
- // Position with adequate prefix to avoid prefix heuristic from skipping
+ // Simulate another LLM request in flight
+ s.llmBusy = true
p := CompletionParams{ Position: Position{ Line: 0, Character: 3 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} }
items, ok := s.tryLLMCompletion(p, "", "foo", "", "", "", false, "")
if !ok {
- t.Fatalf("expected ok=true even when throttled")
+ t.Fatalf("expected ok=true when busy guard skips")
}
if len(items) != 0 {
- t.Fatalf("expected zero items when throttled, got %d", len(items))
+ t.Fatalf("expected zero items when busy, got %d", len(items))
}
if fake.calls != 0 {
- t.Fatalf("LLM Chat should not be called when throttled; calls=%d", fake.calls)
+ t.Fatalf("LLM Chat should not be called when busy; calls=%d", fake.calls)
}
}
@@ -60,9 +57,9 @@ func TestTryLLMCompletion_MinPrefixSkipsEarly(t *testing.T) {
s := &Server{ maxTokens: 32 }
fake := &countingLLM{}
s.llmClient = fake
- // Only 1 identifier character before cursor
- p := CompletionParams{ Position: Position{ Line: 0, Character: 1 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} }
- items, ok := s.tryLLMCompletion(p, "", "a", "", "", "", false, "")
+ // Zero identifier characters before cursor
+ p := CompletionParams{ Position: Position{ Line: 0, Character: 0 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} }
+ items, ok := s.tryLLMCompletion(p, "", "", "", "", "", false, "")
if !ok {
t.Fatalf("expected ok=true when skipped by min-prefix heuristic")
}
@@ -73,3 +70,16 @@ func TestTryLLMCompletion_MinPrefixSkipsEarly(t *testing.T) {
t.Fatalf("LLM Chat should not be called when min-prefix not met; calls=%d", fake.calls)
}
}
+
+func TestTryLLMCompletion_AllowsAfterTrailingSpace(t *testing.T) {
+ s := &Server{ maxTokens: 32 }
+ fake := &countingLLM{}
+ s.llmClient = fake
+ line := "type Matrix "
+ // Cursor after trailing space
+ p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} }
+ items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "")
+ if !ok || len(items) == 0 || fake.calls == 0 {
+ t.Fatalf("expected completion allowed after trailing space; ok=%v len=%d calls=%d", ok, len(items), fake.calls)
+ }
+}
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index 95656df..b9922f9 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -452,26 +452,42 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
defer cancel()
inParams := inParamList(current, p.Position.Character)
- // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers
+ // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers,
+ // but allow immediate completion after structural trigger chars like '.', ':', '/'.
if !inParams {
- start := computeWordStart(current, p.Position.Character)
- if p.Position.Character-start < 2 { // fewer than 2 identifier chars
- return []CompletionItem{}, true
- }
- }
- // Heuristic 2: Throttle LLM calls to avoid rapid-fire requests
- if s.minCompletionInterval > 0 {
- s.mu.Lock()
- tooSoon := time.Since(s.lastLLMCompletion) < s.minCompletionInterval
- // Preemptively update timestamp to coalesce bursts
- if !tooSoon {
- s.lastLLMCompletion = time.Now()
+ // Determine the effective cursor index within current line, clamped, and
+ // skip over trailing spaces/tabs to support cases like "type Matrix| "
+ // where the cursor is after a space following an identifier.
+ idx := p.Position.Character
+ if idx > len(current) { idx = len(current) }
+ // Structural triggers allow no prefix
+ allowNoPrefix := false
+ if idx > 0 {
+ ch := current[idx-1]
+ if ch == '.' || ch == ':' || ch == '/' || ch == '_' {
+ allowNoPrefix = true
+ }
}
- s.mu.Unlock()
- if tooSoon {
- return []CompletionItem{}, true
+ if !allowNoPrefix {
+ // Walk left over whitespace
+ j := idx
+ for j > 0 {
+ c := current[j-1]
+ if c == ' ' || c == '\t' { j--; continue }
+ break
+ }
+ start := computeWordStart(current, j)
+ if j-start < 1 { // require at least 1 identifier char
+ logging.Logf("lsp ", "completion skip=short-prefix line=%d char=%d current=%q", p.Position.Line, p.Position.Character, trimLen(current))
+ return []CompletionItem{}, true
+ }
}
}
+ // Concurrency guard: if another LLM request is running, skip this one.
+ if !s.tryStartLLM() {
+ logging.Logf("lsp ", "completion skip=busy another LLM request in flight")
+ return []CompletionItem{}, true
+ }
sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx)
messages := []llm.Message{
{Role: "system", Content: sysPrompt},
@@ -492,7 +508,9 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
if s.codingTemperature != nil {
opts = append(opts, llm.WithTemperature(*s.codingTemperature))
}
- text, err := s.llmClient.Chat(ctx, messages, opts...)
+ logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel())
+ text, err := s.llmClient.Chat(ctx, messages, opts...)
+ defer s.endLLM()
if err != nil {
logging.Logf("lsp ", "llm completion error: %v", err)
// Log updated averages after this request (even if failed)
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 474020c..7773dd1 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -32,9 +32,8 @@ type Server struct {
triggerChars []string
// If set, used as the LSP coding temperature for all LLM calls
codingTemperature *float64
- // Throttling for LLM-powered completion
- lastLLMCompletion time.Time
- minCompletionInterval time.Duration
+ // Concurrency guard: prevent overlapping LLM requests (esp. completions)
+ llmBusy bool
// LLM request stats
llmReqTotal int64
llmSentBytesTotal int64
@@ -54,7 +53,6 @@ type ServerOptions struct {
Client llm.Client
TriggerCharacters []string
CodingTemperature *float64
- MinCompletionIntervalMs int
}
func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server {
@@ -89,14 +87,28 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions)
s.triggerChars = append([]string{}, opts.TriggerCharacters...)
}
s.codingTemperature = opts.CodingTemperature
- if opts.MinCompletionIntervalMs <= 0 {
- s.minCompletionInterval = 900 * time.Millisecond
- } else {
- s.minCompletionInterval = time.Duration(opts.MinCompletionIntervalMs) * time.Millisecond
- }
return s
}
+// tryStartLLM attempts to mark the LLM as busy. Returns true when it acquired
+// the guard; false if another LLM request is already running.
+func (s *Server) tryStartLLM() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.llmBusy {
+ return false
+ }
+ s.llmBusy = true
+ return true
+}
+
+// endLLM releases the busy guard for LLM requests.
+func (s *Server) endLLM() {
+ s.mu.Lock()
+ s.llmBusy = false
+ s.mu.Unlock()
+}
+
func (s *Server) Run() error {
for {
body, err := s.readMessage()