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:
+49
-8
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user