summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-19 22:56:10 +0300
committerPaul Buetow <paul@buetow.org>2025-09-19 22:56:10 +0300
commit1065d7a991d3b5c103bcc986a84867db28cb4720 (patch)
tree00824c2b7b7b3016f235205bed9483b638340de6 /internal
parent6feda08653a80a7609df2e8b80e98ede15f86a61 (diff)
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/config_alias_test.go36
-rw-r--r--internal/appconfig/config_env_model_test.go37
-rw-r--r--internal/hexaicli/run_model_override_test.go39
-rw-r--r--internal/lsp/transport_concurrency_test.go92
-rw-r--r--internal/stats/lock_posix.go23
-rw-r--r--internal/stats/lock_windows.go24
6 files changed, 251 insertions, 0 deletions
diff --git a/internal/appconfig/config_alias_test.go b/internal/appconfig/config_alias_test.go
new file mode 100644
index 0000000..6cc5bda
--- /dev/null
+++ b/internal/appconfig/config_alias_test.go
@@ -0,0 +1,36 @@
+package appconfig
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestOpenAIPresets_AliasResolution(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfgDir := filepath.Join(dir, "hexai")
+ if err := os.MkdirAll(cfgDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ toml := `
+[provider]
+name = "openai"
+
+[openai]
+model = "codex"
+
+[openai.presets]
+codex = "gpt-5-codex"
+`
+ path := filepath.Join(cfgDir, "config.toml")
+ if err := os.WriteFile(path, []byte(toml), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ cfg := Load(log.New(os.Stderr, "test ", 0))
+ if cfg.OpenAIModel != "gpt-5-codex" {
+ t.Fatalf("expected alias to resolve to gpt-5-codex, got %q", cfg.OpenAIModel)
+ }
+}
+
diff --git a/internal/appconfig/config_env_model_test.go b/internal/appconfig/config_env_model_test.go
new file mode 100644
index 0000000..2db2bb5
--- /dev/null
+++ b/internal/appconfig/config_env_model_test.go
@@ -0,0 +1,37 @@
+package appconfig
+
+import (
+ "log"
+ "os"
+ "testing"
+)
+
+// Test that HEXAI_MODEL applies to provider model fields and that
+// provider-specific envs take precedence when both are set.
+func TestEnv_GenericModelOverrideAndPrecedence(t *testing.T) {
+ t.Setenv("HEXAI_MODEL", "gpt-5-codex")
+ t.Setenv("HEXAI_PROVIDER", "openai")
+ // No provider-specific env set yet: HEXAI_MODEL should flow into OpenAIModel
+ cfg := Load(log.New(os.Stderr, "test ", 0))
+ if cfg.OpenAIModel != "gpt-5-codex" {
+ t.Fatalf("expected OpenAIModel=gpt-5-codex via HEXAI_MODEL, got %q", cfg.OpenAIModel)
+ }
+
+ // Now set a provider-specific model; it should win over HEXAI_MODEL
+ t.Setenv("HEXAI_OPENAI_MODEL", "gpt-5-thinking")
+ cfg2 := Load(log.New(os.Stderr, "test ", 0))
+ if cfg2.OpenAIModel != "gpt-5-thinking" {
+ t.Fatalf("expected OpenAIModel from HEXAI_OPENAI_MODEL to win, got %q", cfg2.OpenAIModel)
+ }
+}
+
+// Test that HEXAI_MODEL_FORCE overrides provider-specific envs (used by CLI --model).
+func TestEnv_ModelForce_OverridesProviderSpecific(t *testing.T) {
+ t.Setenv("HEXAI_OPENAI_MODEL", "gpt-5-main")
+ t.Setenv("HEXAI_MODEL_FORCE", "gpt-5-codex")
+ t.Setenv("HEXAI_PROVIDER", "openai")
+ cfg := Load(log.New(os.Stderr, "test ", 0))
+ if cfg.OpenAIModel != "gpt-5-codex" {
+ t.Fatalf("expected OpenAIModel forced to gpt-5-codex, got %q", cfg.OpenAIModel)
+ }
+}
diff --git a/internal/hexaicli/run_model_override_test.go b/internal/hexaicli/run_model_override_test.go
new file mode 100644
index 0000000..6394bd1
--- /dev/null
+++ b/internal/hexaicli/run_model_override_test.go
@@ -0,0 +1,39 @@
+package hexaicli
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+type fakeClientModelEnv struct{ name, model string }
+func (f fakeClientModelEnv) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "ok", nil }
+func (f fakeClientModelEnv) Name() string { return f.name }
+func (f fakeClientModelEnv) DefaultModel() string { return f.model }
+
+// Ensure that HEXAI_MODEL overrides config for CLI runs.
+func TestRun_ModelEnvOverride_FlowsIntoClient(t *testing.T) {
+ t.Setenv("HEXAI_MODEL", "gpt-5-codex")
+ t.Setenv("HEXAI_PROVIDER", "openai")
+ // Replace client constructor to assert model was overridden
+ oldNew := newClientFromApp
+ defer func() { newClientFromApp = oldNew }()
+ newClientFromApp = func(cfg appconfig.App) (llm.Client, error) {
+ if strings.TrimSpace(cfg.OpenAIModel) != "gpt-5-codex" {
+ t.Fatalf("expected cfg.OpenAIModel=gpt-5-codex, got %q", cfg.OpenAIModel)
+ }
+ return fakeClientModelEnv{name: "openai", model: cfg.OpenAIModel}, nil
+ }
+
+ var out, errb bytes.Buffer
+ if err := Run(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb); err != nil {
+ t.Fatalf("run error: %v", err)
+ }
+ if !strings.Contains(errb.String(), "model=gpt-5-codex") {
+ t.Fatalf("stderr should print effective model, got: %s", errb.String())
+ }
+}
diff --git a/internal/lsp/transport_concurrency_test.go b/internal/lsp/transport_concurrency_test.go
new file mode 100644
index 0000000..f2390c7
--- /dev/null
+++ b/internal/lsp/transport_concurrency_test.go
@@ -0,0 +1,92 @@
+package lsp
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+type raceBuffer struct {
+ buf bytes.Buffer
+ bufMu sync.Mutex
+ inWrite atomic.Int32
+ raced atomic.Bool
+}
+
+func (b *raceBuffer) Write(p []byte) (int, error) {
+ if b.inWrite.Swap(1) != 0 {
+ b.raced.Store(true)
+ }
+ runtime.Gosched()
+ b.bufMu.Lock()
+ n, err := b.buf.Write(p)
+ b.bufMu.Unlock()
+ b.inWrite.Store(0)
+ return n, err
+}
+
+func (b *raceBuffer) Bytes() []byte {
+ b.bufMu.Lock()
+ defer b.bufMu.Unlock()
+ return append([]byte(nil), b.buf.Bytes()...)
+}
+
+func (b *raceBuffer) Raced() bool {
+ return b.raced.Load()
+}
+
+func TestServerReplySerializesWrites(t *testing.T) {
+ t.Parallel()
+
+ writer := &raceBuffer{}
+ srv := NewServer(bytes.NewReader([]byte{}), writer, log.New(io.Discard, "", 0), ServerOptions{})
+
+ const goroutines = 16
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ <-start
+ id := json.RawMessage(strconv.Itoa(i + 1))
+ srv.reply(id, map[string]int{"index": i}, nil)
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+
+ if writer.Raced() {
+ t.Fatalf("detected overlapping writes to server output")
+ }
+
+ data := writer.Bytes()
+ for len(data) > 0 {
+ headerEnd := bytes.Index(data, []byte("\r\n\r\n"))
+ if headerEnd < 0 {
+ t.Fatalf("missing header delimiter in %q", string(data))
+ }
+ header := string(data[:headerEnd])
+ if !strings.HasPrefix(header, "Content-Length: ") {
+ t.Fatalf("unexpected header %q", header)
+ }
+ lengthStr := strings.TrimSpace(header[len("Content-Length: "):])
+ length, err := strconv.Atoi(lengthStr)
+ if err != nil {
+ t.Fatalf("invalid content length %q: %v", lengthStr, err)
+ }
+ payloadStart := headerEnd + 4
+ payloadEnd := payloadStart + length
+ if payloadEnd > len(data) {
+ t.Fatalf("payload truncated: need %d bytes, have %d", length, len(data)-payloadStart)
+ }
+ data = data[payloadEnd:]
+ }
+}
diff --git a/internal/stats/lock_posix.go b/internal/stats/lock_posix.go
new file mode 100644
index 0000000..2c41d31
--- /dev/null
+++ b/internal/stats/lock_posix.go
@@ -0,0 +1,23 @@
+//go:build !windows
+
+package stats
+
+import (
+ "errors"
+
+ "golang.org/x/sys/unix"
+)
+
+func tryLockFile(fd uintptr) error {
+ if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ if errors.Is(err, unix.EWOULDBLOCK) {
+ return errLockWouldBlock
+ }
+ return err
+ }
+ return nil
+}
+
+func unlockFile(fd uintptr) error {
+ return unix.Flock(int(fd), unix.LOCK_UN)
+}
diff --git a/internal/stats/lock_windows.go b/internal/stats/lock_windows.go
new file mode 100644
index 0000000..2ec5e90
--- /dev/null
+++ b/internal/stats/lock_windows.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package stats
+
+import (
+ "golang.org/x/sys/windows"
+)
+
+func tryLockFile(fd uintptr) error {
+ var ol windows.Overlapped
+ err := windows.LockFileEx(windows.Handle(fd), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &ol)
+ if err == nil {
+ return nil
+ }
+ if err == windows.ERROR_LOCK_VIOLATION {
+ return errLockWouldBlock
+ }
+ return err
+}
+
+func unlockFile(fd uintptr) error {
+ var ol windows.Overlapped
+ return windows.UnlockFileEx(windows.Handle(fd), 0, 1, 0, &ol)
+}