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}
}
>