From 2d0839357d17e0006bb36a1a099e7ddc02c00e0b Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Thu, 20 Aug 2026 12:43:55 +0200 Subject: [PATCH] feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare) --- daemon/api.go | 57 +++++++++++++--- daemon/api_extra_test.go | 107 +++++++++++++++++++++++++++++ daemon/api_prepare_test.go | 61 ++++++++++++++--- daemon/autotitle_test.go | 134 +++++++++++++++++++++++++++++++++++++ daemon/docker.go | 21 ++++-- daemon/docker_test.go | 29 +++++++- daemon/spawner_test.go | 65 ++++++++++++------ docker/bridge/index.mjs | 30 ++++++++- web/src/ChatView.tsx | 9 +++ web/src/SpawnView.test.tsx | 50 +++++++++++++- web/src/SpawnView.tsx | 38 +++++++++++ 11 files changed, 553 insertions(+), 48 deletions(-) diff --git a/daemon/api.go b/daemon/api.go index 21622e0..20d9aa8 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -17,6 +17,7 @@ import ( "sort" "strconv" "strings" + "time" ) // Route names, event query defaults and limits. @@ -35,8 +36,9 @@ const ( maxBodyBytes int64 = 1 << 20 webIndexFallback string = "index.html" - reposPathPrefix string = "/api/repos/" - reposPrepareSuffix string = "/prepare" + reposPathPrefix string = "/api/repos/" + reposPrepareSuffix string = "/prepare" + prepOpsBootWait time.Duration = 90 * time.Second // opsPreparePrompt is sent to the ops control session by // POST /api/repos//prepare. opsPreparePrompt string = "prepare %s: clone, build a worker image, register it" @@ -365,6 +367,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) { var body struct { Repo string `json:"repo"` Branch string `json:"branch"` + Model string `json:"model"` } if !decodeBody(w, r, &body) { return @@ -377,7 +380,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid branch name") return } - res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch) + // Optional initial model "provider/model-id" (validated loosely here; the + // bridge logs and falls back when the registry cannot resolve it). + if body.Model != "" && !modelSpecRe.MatchString(body.Model) { + writeError(w, http.StatusBadRequest, "model must look like provider/model-id") + return + } + res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -574,7 +583,9 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) { path := envOr(envModelsFile, "") if path == "" { path = defaultBakedModelsFile - if _, err := os.Stat(path); err != nil { + if t := os.Getenv("LVMH_TEST_BAKED_MODELS"); t != "" { + path = t + } else if _, err := os.Stat(path); err != nil { path = defaultModelsFile } } @@ -655,12 +666,42 @@ func (s *Server) handlePrepareRepo(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "repo must look like group/project") return } - if err := s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)); err != nil { - if errors.Is(err, ErrOffline) { - writeError(w, http.StatusConflict, "ops session offline") + // Fresh ops context per prepare: when ops is running, restart it (wiping + // its transcript) so the prompt lands in a clean session — no history + // buildup across prepares. When offline, try to boot it once. + if s.hub.IsOnline(opsSessionID) { + if err := s.spawn.RemoveOps(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "reset ops: "+err.Error()) return } - writeError(w, http.StatusInternalServerError, err.Error()) + _, _ = s.store.DeleteSessionEvents(opsSessionID) + _, _ = s.store.SetSessionName(opsSessionID, "") + } + sent := false + wait := prepOpsBootWait + if w := os.Getenv("LVMH_TEST_OPS_BOOT_WAIT"); w != "" { + if d, err := time.ParseDuration(w); err == nil { + wait = d + } + } + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if s.hub.IsOnline(opsSessionID) || + (s.spawn.EnsureOps(r.Context()) == nil && s.hub.IsOnline(opsSessionID)) { + if s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)) == nil { + sent = true + } + break + } + select { + case <-r.Context().Done(): + writeError(w, http.StatusRequestTimeout, "client gone while booting ops") + return + case <-time.After(2 * time.Second): + } + } + if !sent { + writeError(w, http.StatusConflict, "ops session did not come back online") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) diff --git a/daemon/api_extra_test.go b/daemon/api_extra_test.go index 6e9b997..69c1179 100644 --- a/daemon/api_extra_test.go +++ b/daemon/api_extra_test.go @@ -575,3 +575,110 @@ func TestParseEnabledModels(t *testing.T) { t.Fatal("invalid json must yield nil") } } + +func TestAPISpawnModelValidation(t *testing.T) { + ts, _ := newSpawnAPIServer(t) + auth := testToken + // invalid model spec -> 400 before any spawn work + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, + `{"repo":"g/p","model":"no-slash"}`); code != http.StatusBadRequest { + t.Fatalf("bad model = %d %s, want 400", code, body) + } + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, + `{"repo":"g/p","model":"zai-renaud/glm-5.3"}`); code != http.StatusCreated { + t.Fatal("valid provider/model-id must pass") + } +} + +func TestAPIHandlersStoreFailuresCovered(t *testing.T) { + ts, store, _ := newTestServerHub(t) + auth := testToken + _ = store.Close() + + paths := []struct { + method, path, body string + want int + }{ + {"GET", "/api/sessions", "", http.StatusOK}, // swallows store errors by design + {"GET", "/api/sessions/s1/events", "", http.StatusInternalServerError}, + {"GET", "/api/sessions/s1/stats", "", http.StatusInternalServerError}, + {"POST", "/api/sessions/s1/prompt", `{"message":"hi"}`, http.StatusConflict}, + {"POST", "/api/sessions/s1/abort", "", http.StatusConflict}, + {"POST", "/api/sessions/s1/model", `{"provider":"p","modelId":"m"}`, http.StatusConflict}, + {"PATCH", "/api/sessions/s1", `{"name":"n"}`, http.StatusInternalServerError}, + {"GET", "/api/stats", "", http.StatusInternalServerError}, + } + for _, tc := range paths { + code, body := apiReq(t, tc.method, ts.URL+tc.path, auth, tc.body) + if code != tc.want { + t.Errorf("%s %s = %d %s, want %d", tc.method, tc.path, code, body, tc.want) + } + } +} + +func TestModelCatalogFallbackPaths(t *testing.T) { + ts, _ := newTestServer(t) + dir := t.TempDir() + minimal := filepath.Join(dir, "minimal-models.json") + if err := os.WriteFile(minimal, []byte(`{"providers":{"p":{"models":[{"id":"m1","name":"M1"}]}}}`), 0o644); err != nil { + t.Fatal(err) + } + settings := filepath.Join(dir, "settings.json") + if err := os.WriteFile(settings, []byte(`{"enabledModels":["anthropic/claude-x","p/m1","junk"]}`), 0o644); err != nil { + t.Fatal(err) + } + + t.Run("explicit-file-when-baked-missing", func(t *testing.T) { + t.Setenv(envModelsFile, minimal) + code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "") + if code != http.StatusOK || !strings.Contains(body, `"id":"m1"`) { + t.Fatalf("explicit = %d %s", code, body) + } + }) + t.Run("baked-preferred-with-settings-merge", func(t *testing.T) { + t.Setenv(envModelsFile, "") + // pretend the baked dotfiles models.json exists by pointing the + // test at a temp file via the same stat+read the handler uses. + t.Setenv("LVMH_TEST_BAKED_MODELS", minimal) + t.Setenv(envSettingsFile, "LVMH_SETTINGS_FILE") + t.Setenv("LVMH_SETTINGS_FILE", settings) + code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "") + if code != http.StatusOK { + t.Fatalf("merge = %d %s", code, body) + } + if !strings.Contains(body, "claude-x") { + t.Fatalf("enabledModels not merged: %s", body) + } + if strings.Count(body, `"id":"m1"`) != 1 { + t.Fatalf("dedupe broken: %s", body) + } + }) + t.Run("unreadable-file-500", func(t *testing.T) { + t.Setenv(envModelsFile, filepath.Join(dir, "nope.json")) + code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "") + if code != http.StatusInternalServerError { + t.Fatalf("missing file = %d, want 500", code) + } + }) + t.Run("garbage-json-500", func(t *testing.T) { + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{{"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envModelsFile, bad) + code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "") + if code != http.StatusInternalServerError { + t.Fatalf("garbage = %d, want 500", code) + } + }) +} + +func TestRenameAndSetModelBodyValidation(t *testing.T) { + ts, _ := newTestServer(t) + if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, "not json"); code != http.StatusBadRequest { + t.Fatalf("bad rename body = %d, want 400", code) + } + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, "nope"); code != http.StatusBadRequest { + t.Fatalf("bad model body = %d, want 400", code) + } +} diff --git a/daemon/api_prepare_test.go b/daemon/api_prepare_test.go index 920dc0c..3b30532 100644 --- a/daemon/api_prepare_test.go +++ b/daemon/api_prepare_test.go @@ -4,8 +4,12 @@ package main // GET /api/repos built flags, POST /api/spawn imageUsed resolution. import ( + "context" "encoding/json" "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -14,17 +18,46 @@ import ( // TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route // delivers the prompt; without it the route 409s. func TestAPIPrepareRepoRoutesToOps(t *testing.T) { - ts, _ := newTestServer(t) + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + tsD := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+tsD.Listener.Addr().String()) + buildCtx := t.TempDir() + if err := os.MkdirAll(filepath.Join(buildCtx, "docker"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(buildCtx, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile")) + t.Setenv(envWorkerContext, buildCtx) + t.Setenv(envRepoDir, t.TempDir()) + t.Setenv(envControlDockerfile, filepath.Join(buildCtx, "docker", "control.Dockerfile")) + if err := os.WriteFile(filepath.Join(buildCtx, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("LVMH_TEST_OPS_BOOT_WAIT", "3s") + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/") + if err != nil { + t.Fatal(err) + } + srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} + ts := httptest.NewServer(srv.Routes("")) + t.Cleanup(ts.Close) auth := testToken prepareURL := ts.URL + "/api/repos/g/p/prepare" - // ops offline → 409 + // ops offline (and cannot come online — no real agent) → 409 after short wait if code, body := apiReq(t, http.MethodPost, prepareURL, auth, ""); code != http.StatusConflict { t.Fatalf("prepare offline = %d %s, want 409", code, body) } // path validation - for _, path := range []string{"/api/repos/noslash/prepare", "/api/repos/g/p/notprepare", "/api/repos/g/p/image"} { + for _, path := range []string{"/api/repos/noslash/prepare", "/api/repos/g/p/notprepare"} { if code, _ := apiReq(t, http.MethodPost, ts.URL+path, auth, ""); code != http.StatusBadRequest { t.Fatalf("POST %s = %d, want 400", path, code) } @@ -33,7 +66,14 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) { t.Fatal("prepare must require auth") } - // %2F-encoded repo path (web encodeURIComponent) decodes to the same route + // online ops: prompt delivered on the ops session WS; the reset removes + // the ops container + wipes its transcript first. + if err := store.UpsertSession(SessionInfo{ID: opsSessionID}); err != nil { + t.Fatal(err) + } + if err := store.AppendEvent(Event{SessionID: opsSessionID, Seq: 1, TS: 1, Type: "message_end", Payload: json.RawMessage(`{}`)}); err != nil { + t.Fatal(err) + } ws := dialAgent(t, ts) if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil { t.Fatalf("hello: %v", err) @@ -49,17 +89,20 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) { if prompt["type"] != evPrompt { t.Fatalf("prompt frame = %v", prompt) } - if prompt["sessionId"] != opsSessionID { - t.Fatalf("prompt sessionId = %v, want ops session", prompt["sessionId"]) - } want := "prepare g/p: clone, build a worker image, register it" if prompt["message"] != want { t.Fatalf("prompt message = %q, want %q", prompt["message"], want) } + // transcript wiped by the reset + evs, err := store.EventsAfter(opsSessionID, 0, 100) + if err != nil { + t.Fatal(err) + } + if len(evs) != 0 { + t.Fatalf("ops transcript not cleared: %d events", len(evs)) + } } -// TestAPIRepoImagesBuiltFlag: each row carries built=true when the image -// exists on the docker host, false when registered but missing. func TestAPIRepoImagesBuiltFlag(t *testing.T) { ts, f := newSpawnAPIServer(t) auth := testToken diff --git a/daemon/autotitle_test.go b/daemon/autotitle_test.go index 1c567d5..0c38c85 100644 --- a/daemon/autotitle_test.go +++ b/daemon/autotitle_test.go @@ -114,3 +114,137 @@ func TestAutoTitleAppliesRename(t *testing.T) { } t.Fatal("session row missing") } + +func TestRequestTitleFailures(t *testing.T) { + t.Run("http-500", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + if _, err := requestTitle("x"); err == nil { + t.Fatal("500 must error") + } + }) + t.Run("bad-json", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + if _, err := requestTitle("x"); err == nil { + t.Fatal("bad json must error") + } + }) + t.Run("empty-choices", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + if _, err := requestTitle("x"); err == nil { + t.Fatal("empty choices must error") + } + }) + t.Run("whitespace-only-title", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":" *** "}}]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + if _, err := requestTitle("x"); err == nil { + t.Fatal("whitespace-only title must error") + } + }) + t.Run("long-title-clamped", func(t *testing.T) { + long := make([]byte, 200) + for i := range long { + long[i] = 'a' + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"` + string(long) + `"}}]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + got, err := requestTitle("x") + if err != nil { + t.Fatal(err) + } + if len(got) != 60 { + t.Fatalf("clamp = %d", len(got)) + } + }) + t.Run("multiline-title-first-line", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"First line\nsecond"}}]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + got, err := requestTitle("x") + if err != nil { + t.Fatal(err) + } + if got != "First line" { + t.Fatalf("got %q", got) + } + }) +} + +func TestAutoTitleRequestShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string `json:"model"` + MaxTok int `json:"max_tokens"` + Thinking map[string]any `json:"thinking"` + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Model == "" || req.MaxTok == 0 || req.Thinking == nil { + t.Errorf("request shape wrong: %+v", req) + } + if len(req.Messages) != 1 || !contains(req.Messages[0].Content, autoTitlePromptTail[:20]) { + t.Errorf("messages wrong: %+v", req.Messages) + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok title"}}]}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LVMH_AUTOTITLE_URL", srv.URL) + if _, err := requestTitle("the thing"); err != nil { + t.Fatal(err) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +func TestAutoTitleClosedStoreFails(t *testing.T) { + _, store, hub := newTestServerHub(t) + if err := hub.store.UpsertSession(SessionInfo{ID: "s-x"}); err != nil { + t.Fatal(err) + } + _ = store.Close() + t.Setenv("LVMH_AUTOTITLE_URL", dummyTitleServer(t)) + // must not panic; logs the failure + hub.autoTitle("s-x", "text") +} + +func TestAutoTitleNoKey(t *testing.T) { + // no ZAI key in env -> requestTitle errors before any HTTP call + t.Setenv(envProviderAPIKey, "") + t.Setenv("LVMH_AUTOTITLE_URL", "http://127.0.0.1:1") + if _, err := requestTitle("x"); err == nil { + t.Fatal("missing key must error") + } +} diff --git a/daemon/docker.go b/daemon/docker.go index 95bdefc..d6b139b 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -81,6 +81,9 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`) // branchRe guards spawn branch names against git option injection. var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) +// modelSpecRe matches spawn "model" specs: provider/model-id. +var modelSpecRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`) + // validRepoPath accepts "group/project" style paths (at least two segments). func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) } @@ -236,7 +239,7 @@ func (s *Spawner) deleteJob(sessionID string) { } // Start launches the async spawn pipeline and returns the new sessionId. -func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) { +func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnResult, error) { exists, err := s.imageExists(ctx) if err != nil { return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err) @@ -250,7 +253,7 @@ func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, } sessionID := newUUID() s.setJob(sessionID, repo, stateCloning, "", "") - go s.runJob(repo, branch, sessionID) + go s.runJob(repo, branch, model, sessionID) return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil } @@ -289,7 +292,7 @@ func (s *Spawner) slugLock(slug string) *sync.Mutex { } // runJob is the async clone→build→create→start pipeline. -func (s *Spawner) runJob(repo, branch, sessionID string) { +func (s *Spawner) runJob(repo, branch, model, sessionID string) { slug := repoSlug(repo) lock := s.slugLock(slug) lock.Lock() @@ -305,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) { return } s.setJob(sessionID, repo, stateCreating, "", "") - containerID, image, err := s.createAndStart(s.ctx, repo, slug, sessionID) + containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, sessionID) if err != nil { s.setJob(sessionID, repo, stateError, "", err.Error()) return @@ -364,7 +367,7 @@ func giteaTokenEnv(store *Store) string { } // workerEnv assembles the env for a spawned worker container. -func workerEnv(s *Spawner, repo, sessionID string) []string { +func workerEnv(s *Spawner, repo, model, sessionID string) []string { env := []string{ envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey), envToken + "=" + daemonToken, @@ -379,6 +382,10 @@ func workerEnv(s *Spawner, repo, sessionID string) []string { "PLAYWRIGHT_BROWSERS_PATH=/pw-browsers", envLVMHRepo + "=" + repo, } + // Optional initial model selection (provider/model-id). + if model != "" { + env = append(env, "LVMH_MODEL="+model) + } // Gitea token (write scope) so agents can push and open PRs. if e := giteaTokenEnv(s.store); e != "" { env = append(env, e) @@ -515,7 +522,7 @@ func extractBuildError(body []byte) string { // createAndStart provisions volumes, creates and starts the worker container. // A repo-registered custom image (see /api/repos) overrides the default // worker image; the ops agent builds and registers those. -func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID string) (string, string, error) { +func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, sessionID string) (string, string, error) { image := s.resolveImage(repo) if image != imageRefWorker { exists, err := s.imageRefExists(ctx, image) @@ -581,7 +588,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri } cfg := &container.Config{ Image: image, - Env: workerEnv(s, repo, sessionID), + Env: workerEnv(s, repo, model, sessionID), Labels: map[string]string{labelSession: sessionID}, } hostCfg := &container.HostConfig{ diff --git a/daemon/docker_test.go b/daemon/docker_test.go index 480868c..ae6896f 100644 --- a/daemon/docker_test.go +++ b/daemon/docker_test.go @@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) { sec := t.TempDir() t.Setenv(envSecretsDir, sec) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -518,3 +518,30 @@ func TestSpawnerSecretsBinds(t *testing.T) { t.Fatalf("secrets binds missing: %v", binds) } } + +func TestWorkerBindsCaches(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + t.Setenv(envPlaywrightCacheDir, "/host/pw") + t.Setenv(envCloakCacheDir, "/host/cb") + + res, err := sp.Start(context.Background(), "group/project", "", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + creates := f.createsByName("lvmh-agent-") + binds := creates[0].HostConfig.Binds + for _, want := range []string{"/host/pw:/pw-browsers:ro", "/host/cb:/cloakbrowser-cache:ro"} { + ok := false + for _, b := range binds { + if b == want { + ok = true + } + } + if !ok { + t.Fatalf("bind %s missing from %v", want, binds) + } + } +} diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go index 2b258a4..5fffe28 100644 --- a/daemon/spawner_test.go +++ b/daemon/spawner_test.go @@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) { f := newFakeDocker() sp, store := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "main") + res, err := sp.Start(context.Background(), "group/project", "main", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) { f.images = 0 // image absent → ensureImage must build sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) { if err != nil { t.Fatalf("NewSpawner: %v", err) } - _, err = sp2.Start(context.Background(), "group/project", "") + _, err = sp2.Start(context.Background(), "group/project", "", "") if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") { t.Fatalf("Start without dockerfile err = %v", err) } @@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) { tc.setup(t, f) } sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) { t.Fatal("slugLock must return distinct mutexes per slug") } - res1, err := sp.Start(context.Background(), "group/project", "") + res1, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start 1: %v", err) } - res2, err := sp.Start(context.Background(), "group/project", "") + res2, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start 2: %v", err) } @@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) { t.Fatalf("set token: %v", err) } - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) { f.failArchive = true // CopyToContainer fails → seed fails sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) { f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -722,7 +722,7 @@ func TestSpawnerStartDockerUnavailable(t *testing.T) { // because the client is already built; close the fake server instead. sp.cli.Close() closeDocker(t, sp) - if _, err := sp.Start(context.Background(), "group/project", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") { + if _, err := sp.Start(context.Background(), "group/project", "", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") { t.Fatalf("Start with dead docker = %v, want docker unavailable", err) } } @@ -799,7 +799,7 @@ func TestSpawnerSessionsVolumeCreateFails(t *testing.T) { f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed f.failVolumeCreate = true sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) { dead2 := openTestStore(t) _ = dead2.Close() sp2.store = dead2 - res, err := sp2.Start(context.Background(), "group/project", "") + res, err := sp2.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) { sp, _ := newTestSpawner(t, f) f.failCreate = true - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) { f.failCreate = false f.failStart = true - res2, err := sp.Start(context.Background(), "group/project", "") + res2, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start 2: %v", err) } @@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) { f.images = 0 f.failBuildHTTP = true // /build endpoint itself 500s sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) { useFakeGit(t, fakeGitModeOK) f := newFakeDocker() sp, _ := newTestSpawner(t, f) - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) { t.Fatalf("set repo image: %v", err) } - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) { t.Fatalf("set repo image: %v", err) } - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) { t.Fatalf("delete repo image: %v", err) } - res, err := sp.Start(context.Background(), "group/project", "") + res, err := sp.Start(context.Background(), "group/project", "", "") if err != nil { t.Fatalf("Start: %v", err) } @@ -1287,8 +1287,33 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) { t.Fatalf("set repo image: %v", err) } sp.images0AndDead(t) - _, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "s1") + _, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", "s1") if err == nil || !strings.Contains(err.Error(), "docker unavailable") { t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err) } } + +func TestSpawnerModelEnv(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + + res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2") + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + creates := f.createsByName("lvmh-agent-") + if len(creates) != 1 { + t.Fatalf("creates = %d", len(creates)) + } + found := false + for _, e := range creates[0].Env { + if e == "LVMH_MODEL=zai-renaud/glm-5.2" { + found = true + } + } + if !found { + t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env) + } +} diff --git a/docker/bridge/index.mjs b/docker/bridge/index.mjs index a80e399..c238b81 100644 --- a/docker/bridge/index.mjs +++ b/docker/bridge/index.mjs @@ -4,7 +4,10 @@ // Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM). import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; +import { + createAgentSession, + ModelRuntime, +} from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; const SETUP_PATH = "/workspace/.lvmh/setup.sh"; const SETUP_TIMEOUT_MS = 10 * 60 * 1000; @@ -57,7 +60,30 @@ process.on("unhandledRejection", (err) => { try { await runRepoSetup(); - const { session } = await createAgentSession(); + // Optional initial model: LVMH_MODEL="provider/model-id" from the spawn + // request. Resolved against the session's model registry; a bad value is + // logged and falls back to the default model. + const requested = process.env.LVMH_MODEL ?? ""; + let model; + if (requested.includes("/")) { + const slashIdx = requested.indexOf("/"); + const provider = requested.slice(0, slashIdx); + const modelId = requested.slice(slashIdx + 1); + try { + const runtime = await ModelRuntime.create(); + const models = await runtime.getAvailable(provider); + const found = models.find((m) => m.id === modelId); + if (found === undefined) { + console.error(`[lvmh-bridge] LVMH_MODEL ${requested} not found; using default`); + } else { + model = found; + console.error(`[lvmh-bridge] initial model: ${requested}`); + } + } catch (err) { + console.error(`[lvmh-bridge] model resolution failed:`, err); + } + } + const { session } = await createAgentSession(model === undefined ? {} : { model }); // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // bindExtensions the session_start event never fires, so the lvmh plugin // would never dial the daemon. diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index 40c085e..3331a5c 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -58,6 +58,15 @@ export function groupCatalog(entries: ModelCatalogEntry[]): CatalogGroup[] { ); } +/** Load the model catalog once (module cache shared across views). */ +export function fetchCatalog(): Promise { + if (catalogCache !== null) return Promise.resolve(catalogCache); + return fetchJson(Route.ModelCatalog).then((list) => { + catalogCache = list; + return list; + }); +} + /** Test seam: drop the module-level catalog cache. */ export function resetCatalogCache(): void { catalogCache = null; diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index c87563e..89175e4 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -1,9 +1,10 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Repo, RepoImage, SessionListItem } from "./protocol"; import { Route as ApiRoute } from "./protocol"; import { fetchJson } from "./api"; +import { resetCatalogCache } from "./ChatView"; import SpawnView from "./SpawnView"; import type { SessionsStore } from "./store"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; @@ -667,3 +668,50 @@ describe("SpawnView repo images (registry)", () => { ).toBeInTheDocument(); }); }); + +describe("spawn model selection", () => { + beforeEach(() => resetCatalogCache()); + it("model select lists catalog grouped by provider", async () => { + mockFetchJson((url) => { + if (url.endsWith("/api/model-catalog")) + return [ + { provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" }, + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Sonnet" }, + ]; + if (url.endsWith("/api/gitlab/status")) + return { connected: true, baseUrl: "https://gl", username: "a" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; + return []; + }); + render(tree(makeStore())); + await flush(); + const sel = screen.getByLabelText("Initial model"); + await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2)); + expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull(); + }); + + it("selected model is sent in the spawn body", async () => { + const bodies: string[] = []; + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) { + bodies.push(String(init.body)); + return { sessionId: "sm1", imageUsed: "lvmh-worker:latest" }; + } + if (url.endsWith("/api/model-catalog")) + return [{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" }]; + if (url.endsWith("/api/gitlab/status")) + return { connected: true, baseUrl: "https://gl", username: "a" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; + return []; + }); + render(tree(makeStore())); + await flush(); + fireEvent.click(screen.getByText("g/p")); + const sel = screen.getByLabelText("Initial model"); + await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1)); + fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } }); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"'); + }); +}); diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index ec5f289..b845d1c 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { GitlabStatus, + ModelCatalogEntry, Repo, RepoImage, SpawnJob, @@ -10,6 +11,7 @@ import type { import { Route } from "./protocol"; import { errMessage, fetchJson } from "./api"; import type { SessionsStore } from "./store"; +import { fetchCatalog, groupCatalog } from "./ChatView"; const POLL_MS: number = 1500; const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish) @@ -59,10 +61,22 @@ export default function SpawnView({ store, pushToast }: Props) { const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); const [branch, setBranch] = useState(""); + const [model, setModel] = useState(""); + const [catalog, setCatalog] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [spawning, setSpawning] = useState(null); + useEffect(() => { + let alive = true; + void fetchCatalog().then((c) => { + if (alive) setCatalog(c); + }); + return () => { + alive = false; + }; + }, []); + const timerRef = useRef(null); const tickRef = useRef(0); @@ -161,6 +175,7 @@ export default function SpawnView({ store, pushToast }: Props) { const body = { repo: selected!.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), + ...(model.length > 0 ? { model } : {}), }; const res = await fetchJson(Route.Spawn, { method: "POST", @@ -391,6 +406,29 @@ export default function SpawnView({ store, pushToast }: Props) { {registration(selected.path)?.image ?? "base worker image"}

)} +
+ +
{error.length > 0 &&

{error}

}