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