feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare)

This commit is contained in:
Raphael Westphal
2026-08-20 12:43:55 +02:00
parent 5292a69d66
commit 2d0839357d
11 changed files with 553 additions and 48 deletions
+49 -8
View File
@@ -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/<repo>/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})
+107
View File
@@ -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)
}
}
+52 -9
View File
@@ -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
+134
View File
@@ -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")
}
}
+14 -7
View File
@@ -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{
+28 -1
View File
@@ -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)
}
}
}
+45 -20
View File
@@ -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)
}
}
+28 -2
View File
@@ -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.
+9
View File
@@ -58,6 +58,15 @@ export function groupCatalog(entries: ModelCatalogEntry[]): CatalogGroup[] {
);
}
/** Load the model catalog once (module cache shared across views). */
export function fetchCatalog(): Promise<ModelCatalogEntry[]> {
if (catalogCache !== null) return Promise.resolve(catalogCache);
return fetchJson<ModelCatalogEntry[]>(Route.ModelCatalog).then((list) => {
catalogCache = list;
return list;
});
}
/** Test seam: drop the module-level catalog cache. */
export function resetCatalogCache(): void {
catalogCache = null;
+49 -1
View File
@@ -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"');
});
});
+38
View File
@@ -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<string>("");
const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>("");
const [model, setModel] = useState<string>("");
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [busy, setBusy] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
useEffect(() => {
let alive = true;
void fetchCatalog().then((c) => {
if (alive) setCatalog(c);
});
return () => {
alive = false;
};
}, []);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number>(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<SpawnResponse>(Route.Spawn, {
method: "POST",
@@ -391,6 +406,29 @@ export default function SpawnView({ store, pushToast }: Props) {
{registration(selected.path)?.image ?? "base worker image"}
</p>
)}
<div className="row" style={{ marginTop: 8 }}>
<select
aria-label="Initial model"
value={model}
disabled={selected === null}
onChange={(e) => setModel(e.target.value)}
>
<option value="">Default model (settings)</option>
{catalog !== null &&
groupCatalog(catalog).map((g) => (
<optgroup key={g.provider} label={g.provider}>
{g.models.map((m) => (
<option
key={`${m.provider}/${m.id}`}
value={`${m.provider}/${m.id}`}
>
{m.name}
</option>
))}
</optgroup>
))}
</select>
</div>
{error.length > 0 && <p className="error-text">{error}</p>}
</div>
</>