diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-24 23:21:43 +0300 |
| commit | c3c71345db9086392cd9b7529c7f5287009c226e (patch) | |
| tree | d227894ab900d6050cbe1418984526088a692db5 /internal | |
| parent | 127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff) | |
Add runtime config store and reload command
Diffstat (limited to 'internal')
40 files changed, 1368 insertions, 570 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 9119688..adf9b75 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -162,7 +162,16 @@ func newDefaultConfig() App { // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { +func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } + +// LoadOptions tune how configuration is loaded at runtime. +type LoadOptions struct { + // IgnoreEnv skips applying environment overrides when true. + IgnoreEnv bool +} + +// LoadWithOptions reads configuration and applies the requested loading options. +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { cfg := newDefaultConfig() if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) @@ -171,18 +180,20 @@ func Load(logger *log.Logger) App { configPath, err := getConfigPath() if err != nil { logger.Printf("%v", err) - // Even if config path cannot be resolved, still allow env overrides below. + // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below. } else { if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below. + // apply any environment overrides below (unless disabled). } - // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { - cfg.mergeWith(envCfg) + if !opts.IgnoreEnv { + // Environment overrides (take precedence over file) + if envCfg := loadFromEnv(logger); envCfg != nil { + cfg.mergeWith(envCfg) + } } return cfg } diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go index 1c0eb51..a3e7f25 100644 --- a/internal/hexaiaction/run_more_test.go +++ b/internal/hexaiaction/run_more_test.go @@ -4,7 +4,11 @@ import ( "bytes" "context" "os" + "strings" "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" ) // Covers the early error path in Run when no API key is available for the default provider. @@ -23,3 +27,78 @@ func TestRun_MissingAPIKey(t *testing.T) { } _ = os.Stderr } + +type stubChatDoer struct { + calls int + msgs [][]llm.Message +} + +func (s *stubChatDoer) Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { + s.calls++ + s.msgs = append(s.msgs, msgs) + return "ok", nil +} + +func (s *stubChatDoer) DefaultModel() string { return "stub" } + +func TestHandleDiagnosticsActionInvokesLLM(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "0") + parts := InputParts{Diagnostics: []string{"warn1"}, Selection: "code"} + client := &stubChatDoer{} + cfg := appconfig.Load(nil) + if _, err := handleDiagnosticsAction(context.Background(), parts, cfg, client); err != nil { + t.Fatalf("handleDiagnosticsAction: %v", err) + } + if client.calls != 1 { + t.Fatalf("expected 1 chat call, got %d", client.calls) + } + found := false + for _, msg := range client.msgs[0] { + if msg.Role == "user" && strings.Contains(msg.Content, "warn1") { + found = true + } + } + if !found { + t.Fatalf("expected diagnostics content in message: %#v", client.msgs[0]) + } +} + +func TestHandleSimplifyActionPassesSelection(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "0") + parts := InputParts{Selection: "value := 1"} + client := &stubChatDoer{} + cfg := appconfig.Load(nil) + if _, err := handleSimplifyAction(context.Background(), parts, cfg, client); err != nil { + t.Fatalf("handleSimplifyAction: %v", err) + } + if client.calls != 1 { + t.Fatalf("expected single chat invocation, got %d", client.calls) + } + seen := false + for _, msg := range client.msgs[0] { + if msg.Role == "user" && strings.Contains(msg.Content, "value := 1") { + seen = true + } + } + if !seen { + t.Fatalf("expected selection echoed in prompt: %#v", client.msgs[0]) + } +} + +func TestHandleCustomActionUsesSelectedCustom(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "0") + sel := appconfig.CustomAction{ID: "custom", Title: "Do", Instruction: "do it"} + selectedCustom = &sel + parts := InputParts{Selection: "text"} + client := &stubChatDoer{} + cfg := appconfig.Load(nil) + if _, err := handleCustomAction(context.Background(), parts, cfg, client); err != nil { + t.Fatalf("handleCustomAction: %v", err) + } + if client.calls != 1 { + t.Fatalf("expected custom action to invoke chat, got %d calls", client.calls) + } + if selectedCustom != nil { + t.Fatal("expected selectedCustom to be cleared") + } +} diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 554e604..ffb9f86 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -13,6 +13,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/runtimeconfig" "codeberg.org/snonux/hexai/internal/stats" ) @@ -55,8 +56,26 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l client = buildClientIfNil(cfg, client) factory = ensureFactory(factory) - opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client) + store := runtimeconfig.New(cfg) + logContext := strings.TrimSpace(logPath) != "" + opts := makeServerOptions(cfg, logContext, client) + opts.ConfigStore = store server := factory(stdin, stdout, logger, opts) + if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok { + store.Subscribe(func(oldCfg, newCfg appconfig.App) { + updated := newCfg + normalizeLoggingConfig(&updated) + if updated.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute) + } + if newClient := buildClientIfNil(updated, nil); newClient != nil { + client = newClient + } + opts := makeServerOptions(updated, logContext, client) + opts.ConfigStore = store + configurable.ApplyOptions(opts) + }) + } if err := server.Run(); err != nil { logger.Fatalf("server error: %v", err) } @@ -135,6 +154,8 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls } return lsp.ServerOptions{ LogContext: logContext, + ConfigStore: nil, + Config: &cfg, MaxTokens: cfg.MaxTokens, ContextMode: cfg.ContextMode, WindowLines: cfg.ContextWindowLines, diff --git a/internal/hexailsp/run_more_test.go b/internal/hexailsp/run_more_test.go index 00b79c1..faaae41 100644 --- a/internal/hexailsp/run_more_test.go +++ b/internal/hexailsp/run_more_test.go @@ -2,18 +2,34 @@ package hexailsp import ( "bytes" + "context" "io" "log" "testing" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/runtimeconfig" ) type recRunner struct{ ran bool } func (r *recRunner) Run() error { r.ran = true; return nil } +type applyRunner struct{ opts []lsp.ServerOptions } + +func (r *applyRunner) Run() error { return nil } +func (r *applyRunner) ApplyOptions(opts lsp.ServerOptions) { r.opts = append(r.opts, opts) } + +type stubClient struct{} + +func (stubClient) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { + return "", nil +} +func (stubClient) Name() string { return "stub" } +func (stubClient) DefaultModel() string { return "stub-model" } + func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) { var captured lsp.ServerOptions factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner { @@ -41,3 +57,41 @@ func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) { t.Fatalf("expected client to be constructed") } } + +func TestRunWithFactory_SubscriptionAppliesUpdates(t *testing.T) { + var in, out bytes.Buffer + logger := log.New(io.Discard, "", 0) + runner := &applyRunner{} + var capturedStore *runtimeconfig.Store + factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner { + capturedStore = opts.ConfigStore + runner.opts = append(runner.opts, opts) + return runner + } + cfg := appconfig.Load(nil) + cfg.StatsWindowMinutes = 0 + cfg.ContextMode = " WINDOW " + if err := RunWithFactory("", &in, &out, logger, cfg, stubClient{}, factory); err != nil { + t.Fatalf("RunWithFactory error: %v", err) + } + if capturedStore == nil { + t.Fatal("expected config store to be passed to factory") + } + if len(runner.opts) == 0 { + t.Fatal("expected initial options to be recorded") + } + updated := cfg + updated.MaxTokens = cfg.MaxTokens + 10 + updated.ContextMode = "always-full" + capturedStore.Set(updated) + if len(runner.opts) < 2 { + t.Fatalf("expected ApplyOptions to be invoked on config update, got %d calls", len(runner.opts)) + } + latest := runner.opts[len(runner.opts)-1] + if latest.MaxTokens != updated.MaxTokens { + t.Fatalf("expected updated max tokens, got %+v", latest) + } + if latest.ContextMode != "always-full" { + t.Fatalf("expected normalized context mode, got %+v", latest) + } +} diff --git a/internal/llm/copilot_http_test.go b/internal/llm/copilot_http_test.go index d66311c..9dd4aee 100644 --- a/internal/llm/copilot_http_test.go +++ b/internal/llm/copilot_http_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "os" @@ -22,7 +23,7 @@ func TestCopilot_EnsureSession_AndChat_Success(t *testing.T) { t.Skip("skip network-bound tests in restricted environments") } // Mock chat endpoint - chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + chatSrv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Fatalf("unexpected path: %s", r.URL.Path) } @@ -92,7 +93,7 @@ func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) { t.Skip("skip network-bound tests in restricted environments") } // Chat multi-choice: return two choices; client returns first content - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []map[string]any{ {"index": 0, "finish_reason": "stop", "message": map[string]string{"role": "assistant", "content": "FIRST"}}, @@ -120,7 +121,7 @@ func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) { } // Non-2xx with error body - srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv2 := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "denied", "type": "forbidden"}}) })) @@ -136,7 +137,7 @@ func TestCopilot_Chat_NoChoices_Error(t *testing.T) { if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}}) })) defer srv.Close() @@ -162,7 +163,7 @@ func TestCopilot_Chat_DecodeError_StatusOK(t *testing.T) { t.Skip("skip network-bound tests in restricted environments") } // Chat returns 200 but invalid JSON; expect decode error - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := newIPv4Server(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "{invalid") })) defer srv.Close() @@ -254,6 +255,20 @@ func TestParseJWTExp_AndParseInt64(t *testing.T) { } } +func newIPv4Server(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + l, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on tcp4: %v", err) + } + srv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: handler}, + } + srv.Start() + return srv +} + // bytesReader wraps a byte slice with an io.ReadCloser without importing extra. type bytesReader []byte diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go index f7ce080..686d535 100644 --- a/internal/llm/openai_test.go +++ b/internal/llm/openai_test.go @@ -1,67 +1,89 @@ package llm import ( - "bytes" - "encoding/json" + "context" "io" "net/http" "strings" "testing" - "time" + + "codeberg.org/snonux/hexai/internal/logging" ) -func f64p(v float64) *float64 { return &v } +func TestOpenAIChatSuccess(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("expected auth header, got %q", got) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}]}`)), + Header: make(http.Header), + }, nil + }) -func TestBuildOAChatRequest_TempFallbackAndFields(t *testing.T) { - o := Options{Model: "m1", Temperature: 0, MaxTokens: 42, Stop: []string{"END"}} - msgs := []Message{{Role: "user", Content: "hi"}} - req := buildOAChatRequest(o, msgs, f64p(0.3), false) - if req.Model != "m1" || req.Stream { - t.Fatalf("model/stream mismatch: %+v", req) - } - if req.Temperature == nil || *req.Temperature != 0.3 { - t.Fatalf("expected default temp 0.3, got %#v", req.Temperature) - } - if req.MaxTokens == nil || *req.MaxTokens != 42 { - t.Fatalf("expected max tokens 42") + client := openAIClient{ + httpClient: &http.Client{Transport: transport}, + apiKey: "test-key", + baseURL: "https://example.com", + defaultModel: "gpt-test", + chatLogger: logging.NewChatLogger("openai"), } - if len(req.Stop) != 1 || req.Stop[0] != "END" { - t.Fatalf("stop not propagated: %#v", req.Stop) + + out, err := client.Chat(context.Background(), []Message{{Role: "user", Content: "hello"}}) + if err != nil { + t.Fatalf("Chat returned error: %v", err) } - if len(req.Messages) != 1 || req.Messages[0].Content != "hi" { - t.Fatalf("messages not copied") + if out != "hi there" { + t.Fatalf("unexpected chat output: %q", out) } +} - // stream on - req2 := buildOAChatRequest(o, msgs, f64p(0.3), true) - if !req2.Stream { - t.Fatalf("expected stream=true") +func TestOpenAIChatStreamDeliversChunks(t *testing.T) { + client := openAIClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + body := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n" + + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n" + + "data: [DONE]\n" + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })}, + apiKey: "test-key", + baseURL: "https://example.com", + defaultModel: "gpt-test", + chatLogger: logging.NewChatLogger("openai"), } -} -func TestHandleOpenAINon2xx_WithAPIError(t *testing.T) { - api := oaChatResponse{Error: &struct { - Message string `json:"message"` - Type string `json:"type"` - Param any `json:"param"` - Code any `json:"code"` - }{Message: "bad", Type: "invalid"}} - b, _ := json.Marshal(api) - resp := &http.Response{StatusCode: 400, Body: io.NopCloser(bytes.NewReader(b))} - if err := handleOpenAINon2xx(resp, time.Now()); err == nil { - t.Fatalf("expected error for non-2xx with body") + var received string + err := client.ChatStream(context.Background(), []Message{{Role: "user", Content: "hello"}}, func(chunk string) { + received += chunk + }) + if err != nil { + t.Fatalf("ChatStream returned error: %v", err) + } + if received != "Hello" { + t.Fatalf("expected streamed content, got %q", received) } } -func TestParseOpenAIStream_DeliversChunks(t *testing.T) { - stream := "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n" + - "data: [DONE]\n" - resp := &http.Response{Body: io.NopCloser(strings.NewReader(stream))} - var got strings.Builder - if err := parseOpenAIStream(resp, time.Now(), func(s string) { got.WriteString(s) }); err != nil { - t.Fatalf("unexpected error: %v", err) +func TestOpenAIChatHandlesNon2xx(t *testing.T) { + client := openAIClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("denied")), Header: make(http.Header)}, nil + })}, + apiKey: "test-key", + baseURL: "https://example.com", + defaultModel: "gpt-test", + chatLogger: logging.NewChatLogger("openai"), } - if got.String() != "Hi" { - t.Fatalf("got %q want %q", got.String(), "Hi") + + if _, err := client.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}); err == nil { + t.Fatal("expected error for non-2xx response") } } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/internal/llm/test_helpers_test.go b/internal/llm/test_helpers_test.go new file mode 100644 index 0000000..051747a --- /dev/null +++ b/internal/llm/test_helpers_test.go @@ -0,0 +1,3 @@ +package llm + +func f64p(v float64) *float64 { return &v } diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go new file mode 100644 index 0000000..31347e9 --- /dev/null +++ b/internal/lsp/chat_commands.go @@ -0,0 +1,63 @@ +package lsp + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +type chatCommandResult struct { + message string +} + +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) { + trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") { + return chatCommandResult{}, false + } + + switch { + case strings.HasPrefix(trimmed, "/reload"): + return s.handleReloadCommand(), true + case strings.HasPrefix(trimmed, "/help"): + return s.handleHelpCommand(), true + default: + return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true + } +} + +func (s *Server) handleHelpCommand() chatCommandResult { + lines := []string{ + "Available slash commands:", + "- /reload?> reload configuration from file (ignores env overrides)", + } + return chatCommandResult{message: strings.Join(lines, "\n")} +} + +func (s *Server) handleReloadCommand() chatCommandResult { + if s.configStore == nil { + return chatCommandResult{message: "Reload unavailable: no config store"} + } + changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true}) + if err != nil { + s.logger.Printf("config reload failed: %v", err) + return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)} + } + summary := formatReloadSummary(changes) + s.logger.Print(summary) + return chatCommandResult{message: summary} +} + +func formatReloadSummary(changes []runtimeconfig.Change) string { + if len(changes) == 0 { + return "Reloaded config (no changes detected)." + } + lines := make([]string, 0, len(changes)+1) + lines = append(lines, fmt.Sprintf("Reloaded config (%d changes):", len(changes))) + for _, ch := range changes { + lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New)) + } + return strings.Join(lines, "\n") +} diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go new file mode 100644 index 0000000..bedfaed --- /dev/null +++ b/internal/lsp/chat_commands_test.go @@ -0,0 +1,82 @@ +package lsp + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +func TestFormatReloadSummary(t *testing.T) { + changes := []runtimeconfig.Change{ + {Key: "max_tokens", Old: "200", New: "128"}, + {Key: "provider", Old: "openai", New: "ollama"}, + } + got := formatReloadSummary(changes) + if !strings.Contains(got, "Reloaded config (2 changes):") { + t.Fatalf("expected change count line, got %q", got) + } + if !strings.Contains(got, "max_tokens: 200") || !strings.Contains(got, "provider: openai") { + t.Fatalf("expected formatted entries, got %q", got) + } +} + +func TestHandleHelpCommandListsReload(t *testing.T) { + s := newTestServer() + res := s.handleHelpCommand() + if !strings.Contains(res.message, "/reload?>") { + t.Fatalf("expected reload command in help output: %q", res.message) + } +} + +func TestHandleReloadCommandReloadsStore(t *testing.T) { + tmp := t.TempDir() + configDir := filepath.Join(tmp, "hexai") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + configPath := filepath.Join(configDir, "config.toml") + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEXAI_MAX_TOKENS", "321") + + var logBuf bytes.Buffer + logger := log.New(&logBuf, "", 0) + + initial := appconfig.Load(logger) + if initial.MaxTokens != 321 { + t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens) + } + + store := runtimeconfig.New(initial) + + s := newTestServer() + s.logger = logger + s.configStore = store + + if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil { + t.Fatalf("update config: %v", err) + } + + res := s.handleReloadCommand() + if !strings.Contains(res.message, "Reloaded config (1 changes):") { + t.Fatalf("unexpected reload summary: %q", res.message) + } + if !strings.Contains(res.message, "max_tokens: 321") || !strings.Contains(res.message, "128") { + t.Fatalf("expected diff for max_tokens: %q", res.message) + } + if store.Snapshot().MaxTokens != 128 { + t.Fatalf("expected snapshot to reflect new value, got %d", store.Snapshot().MaxTokens) + } + if !strings.Contains(logBuf.String(), "Reloaded config") { + t.Fatalf("expected summary logged, got %q", logBuf.String()) + } +} diff --git a/internal/lsp/chat_context_mode_test.go b/internal/lsp/chat_context_mode_test.go index 85fa4a9..895c2f3 100644 --- a/internal/lsp/chat_context_mode_test.go +++ b/internal/lsp/chat_context_mode_test.go @@ -11,9 +11,9 @@ import ( func TestChat_RespectsContextModeWindow(t *testing.T) { s := newTestServer() // Configure window mode with small window - s.contextMode = "window" - s.windowLines = 2 - s.maxContextTokens = 2000 + s.cfg.ContextMode = "window" + s.cfg.ContextWindowLines = 2 + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -54,8 +54,8 @@ func TestChat_RespectsContextModeWindow(t *testing.T) { func TestChat_ContextModeMinimal_NoExtra(t *testing.T) { s := newTestServer() - s.contextMode = "minimal" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "minimal" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -78,8 +78,8 @@ func TestChat_ContextModeMinimal_NoExtra(t *testing.T) { func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) { s := newTestServer() - s.contextMode = "always-full" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "always-full" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -108,8 +108,8 @@ func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) { func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) { s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 2000 + s.cfg.ContextMode = "file-on-new-func" + s.cfg.MaxContextTokens = 2000 cap := &captureLLM{} s.llmClient = cap var out bytes.Buffer @@ -129,8 +129,8 @@ func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) { func TestChat_ContextModeFileOnNewFunc_WithSignature_AddsExtra(t *testing.T) { s := newTestServer() - s.contextMode = "file-on-new-func" - s.maxContextTokens = 2000 |
