Compare commits
16
Commits
ecee633479
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4755f6d1e3 | ||
|
|
6ef27e5cd6 | ||
|
|
b62e4e717a | ||
|
|
310127cf7a | ||
|
|
09fc72556d | ||
|
|
0f245c9ee2 | ||
|
|
c8cde3ff89 | ||
|
|
7ff9f77f40 | ||
|
|
5962b50c8d | ||
|
|
3b799c69fb | ||
|
|
f5ee3a0f2d | ||
|
|
bdfa61a8b2 | ||
|
|
2d0839357d | ||
|
|
5292a69d66 | ||
|
|
1b6bd54a74 | ||
|
|
9d369f410e |
+3
-1
@@ -10,7 +10,9 @@ COPY daemon/ ./
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/lvmh-daemon .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
# bash + rsync: POST /api/pi-config/resync runs deploy/rsync-pi-agent.sh
|
||||
# inside this container (alpine has neither by default)
|
||||
RUN apk add --no-cache ca-certificates git bash rsync python3 nodejs npm
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/lvmh-daemon /app/lvmh-daemon
|
||||
# Placeholder UI; replace via build (cp web/dist daemon/webdist) or mount at
|
||||
|
||||
+57
-9
@@ -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,11 +367,19 @@ 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"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !validRepoPath(body.Repo) {
|
||||
// Blank-container spawns carry no repo; everything else must look like
|
||||
// group/project.
|
||||
if body.Repo == "" && !body.Empty {
|
||||
writeError(w, http.StatusBadRequest, "repo required (or set empty for a blank container)")
|
||||
return
|
||||
}
|
||||
if body.Repo != "" && !validRepoPath(body.Repo) {
|
||||
writeError(w, http.StatusBadRequest, "repo must look like group/project")
|
||||
return
|
||||
}
|
||||
@@ -377,7 +387,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, body.Empty)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -574,7 +590,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 +673,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,127 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISpawnBlankContainerNoRepo(t *testing.T) {
|
||||
ts, _ := newSpawnAPIServer(t)
|
||||
|
||||
// no repo and not empty → 400
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"repo":""}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("no-repo non-empty spawn = %d %s, want 400", code, body)
|
||||
}
|
||||
// blank container: no repo required
|
||||
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"empty":true}`)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("blank spawn = %d %s, want 201", code, body)
|
||||
}
|
||||
if !strings.Contains(body, `"sessionId"`) {
|
||||
t.Fatalf("blank spawn body = %s, want sessionId", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
// autotitle.go — LLM-generated session titles. When the first user message
|
||||
// of an unnamed session persists, the daemon makes one cheap chat-completion
|
||||
// call (the default provider, ZAI-compatible endpoint) asking for a 3-6 word
|
||||
// title, then stores it via the normal rename path so session_list, the web
|
||||
// sidebar and the tab title pick it up.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
autoTitleMaxChars int = 400 // prompt excerpt cap
|
||||
autoTitleTimeout time.Duration = 20 * time.Second
|
||||
autoTitlePromptTail string = "\n\nReply with ONLY a 3-6 word title for this request. No quotes, no punctuation at the end, no explanation."
|
||||
)
|
||||
|
||||
var autoTitleTried = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]bool
|
||||
}{m: map[string]bool{}}
|
||||
|
||||
// needsAutoTitle reports whether frame f is a persisted user message_end of
|
||||
// a session that has no name yet and no prior attempt.
|
||||
func (h *Hub) needsAutoTitle(f frame) bool {
|
||||
if f.typ != evMessageEnd {
|
||||
return false
|
||||
}
|
||||
var probe struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(f.raw, &probe); err != nil || probe.Message.Role != "user" {
|
||||
return false
|
||||
}
|
||||
autoTitleTried.mu.Lock()
|
||||
defer autoTitleTried.mu.Unlock()
|
||||
if autoTitleTried.m[f.sessionID] {
|
||||
return false
|
||||
}
|
||||
if rows, err := h.store.Sessions(); err == nil {
|
||||
for _, row := range rows {
|
||||
if row.Info.ID == f.sessionID {
|
||||
if row.Info.Name != nil && *row.Info.Name != "" {
|
||||
autoTitleTried.m[f.sessionID] = true
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
autoTitleTried.m[f.sessionID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// firstUserText extracts the message text from a message_end frame payload.
|
||||
func firstUserText(raw []byte) string {
|
||||
var probe struct {
|
||||
Message struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return ""
|
||||
}
|
||||
return probe.Message.Text
|
||||
}
|
||||
|
||||
// autoTitle asks the provider API for a title and applies it on success.
|
||||
func (h *Hub) autoTitle(sessionID, userText string) {
|
||||
title, err := requestTitle(userText)
|
||||
if err != nil {
|
||||
log.Printf("autotitle %s: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
if _, err := h.store.SetSessionName(sessionID, title); err != nil {
|
||||
log.Printf("autotitle %s: set name: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
// live conns see it via session_list; an open chat also refreshes on it.
|
||||
h.BroadcastSessionList()
|
||||
}
|
||||
|
||||
// requestTitle performs the one-shot completion against the ZAI-compatible
|
||||
// endpoint configured for the default provider.
|
||||
func requestTitle(userText string) (string, error) {
|
||||
key := os.Getenv(envProviderAPIKey)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("no %s configured", envProviderAPIKey)
|
||||
}
|
||||
if len(userText) > autoTitleMaxChars {
|
||||
userText = userText[:autoTitleMaxChars]
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": envOr("LVMH_AUTOTITLE_MODEL", "glm-5.3"),
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": userText + autoTitlePromptTail},
|
||||
},
|
||||
"max_tokens": 512,
|
||||
"thinking": map[string]any{"type": "disabled"},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), autoTitleTimeout)
|
||||
defer cancel()
|
||||
endpoint := envOr("LVMH_AUTOTITLE_URL", "https://api.z.ai/api/coding/paas/v4/chat/completions")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("title api: %s", resp.Status)
|
||||
}
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty choices")
|
||||
}
|
||||
title := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
title = strings.Trim(title, "\"'`*. ")
|
||||
if idx := strings.IndexAny(title, "\n"); idx >= 0 {
|
||||
title = strings.TrimSpace(title[:idx])
|
||||
}
|
||||
if len(title) > 60 {
|
||||
title = title[:60]
|
||||
}
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("empty title")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
// autotitle_test.go — needsAutoTitle gating, firstUserText extraction,
|
||||
// requestTitle against a fake ZAI endpoint, apply path renames + broadcasts.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func userMsgEndFrame(sid string, seq int64, text string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"v": 1, "sessionId": sid, "seq": seq, "ts": 1, "type": "message_end",
|
||||
"message": map[string]any{"role": "user", "id": "u", "text": text,
|
||||
"thinking": nil, "toolCalls": []any{}, "toolCallId": nil},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func TestNeedsAutoTitleGating(t *testing.T) {
|
||||
_, _, hub := newTestServerHub(t)
|
||||
f := frame{typ: evMessageEnd, sessionID: "s-a", raw: userMsgEndFrame("s-a", 1, "hello")}
|
||||
if !hub.needsAutoTitle(f) {
|
||||
t.Fatal("first unnamed user message must want a title")
|
||||
}
|
||||
if hub.needsAutoTitle(f) {
|
||||
t.Fatal("second attempt for same session must be gated")
|
||||
}
|
||||
// assistant frames never trigger
|
||||
fa := frame{typ: evMessageEnd, sessionID: "s-b", raw: []byte(`{"message":{"role":"assistant"}}`)}
|
||||
if hub.needsAutoTitle(fa) {
|
||||
t.Fatal("assistant message must not trigger")
|
||||
}
|
||||
// named session never triggers (persisted via UpsertSession)
|
||||
if err := hub.store.UpsertSession(SessionInfo{ID: "s-c", Name: strPtr("named")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fc := frame{typ: evMessageEnd, sessionID: "s-c", raw: userMsgEndFrame("s-c", 1, "hi")}
|
||||
if hub.needsAutoTitle(fc) {
|
||||
t.Fatal("named session must not trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func dummyTitleServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Auto generated title"}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func TestFirstUserText(t *testing.T) {
|
||||
if got := firstUserText(userMsgEndFrame("s", 1, "build me a thing")); got != "build me a thing" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := firstUserText([]byte("garbage")); got != "" {
|
||||
t.Fatalf("garbage -> %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestTitle(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if len(req.Messages) == 0 || req.Messages[0].Content == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]string{"content": " \"Fix login timeout bug\".\n"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||
got, err := requestTitle("the login page times out after 5s in production")
|
||||
if err != nil {
|
||||
t.Fatalf("requestTitle: %v", err)
|
||||
}
|
||||
if got != "Fix login timeout bug" {
|
||||
t.Fatalf("title = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleAppliesRename(t *testing.T) {
|
||||
_, _, hub := newTestServerHub(t)
|
||||
t.Setenv("LVMH_AUTOTITLE_URL", dummyTitleServer(t))
|
||||
if err := hub.store.UpsertSession(SessionInfo{ID: "s-t"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub.autoTitle("s-t", "anything")
|
||||
rows, err := hub.store.Sessions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Info.ID == "s-t" {
|
||||
if row.Info.Name == nil || *row.Info.Name == "" {
|
||||
t.Fatal("name not applied")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
+64
-27
@@ -47,11 +47,14 @@ const (
|
||||
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
|
||||
cacheMount string = "/root/.pi/agent/cache"
|
||||
authMountTarget string = "/root/.pi/agent/auth.json"
|
||||
modelsMountTarget string = "/root/.pi/agent/models.json"
|
||||
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
|
||||
envHostPiRuntimeDir string = "LVMH_HOST_PI_RUNTIME_DIR"
|
||||
envSecretsDir string = "LVMH_SECRETS_DIR"
|
||||
envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR"
|
||||
envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR"
|
||||
sshMountTarget string = "/root/.ssh"
|
||||
meshMountTarget string = "/root/.pi/spawn-pi"
|
||||
gitconfigMountTarget string = "/root/.gitconfig"
|
||||
workspaceMount string = "/workspace"
|
||||
sessionsMount string = "/pi-sessions"
|
||||
@@ -81,6 +84,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 +242,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, empty bool) (SpawnResult, error) {
|
||||
exists, err := s.imageExists(ctx)
|
||||
if err != nil {
|
||||
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
||||
@@ -250,7 +256,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, empty, sessionID)
|
||||
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
|
||||
}
|
||||
|
||||
@@ -289,15 +295,18 @@ 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 string, empty bool, sessionID string) {
|
||||
slug := repoSlug(repo)
|
||||
lock := s.slugLock(slug)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||
return
|
||||
// blank-container spawn: nothing to clone or update
|
||||
if repo != "" {
|
||||
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
s.setJob(sessionID, repo, stateBuilding, "", "")
|
||||
if err := s.ensureImage(s.ctx); err != nil {
|
||||
@@ -305,7 +314,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, empty, sessionID)
|
||||
if err != nil {
|
||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||
return
|
||||
@@ -364,7 +373,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 +388,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 +528,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 string, empty bool, sessionID string) (string, string, error) {
|
||||
image := s.resolveImage(repo)
|
||||
if image != imageRefWorker {
|
||||
exists, err := s.imageRefExists(ctx, image)
|
||||
@@ -526,19 +539,25 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
||||
return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
|
||||
}
|
||||
}
|
||||
repoVolume := volumeRepoPrefix + slug
|
||||
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if fresh {
|
||||
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
|
||||
// drop the half-seeded volume so the next spawn retries fresh
|
||||
// instead of silently booting into an empty workspace.
|
||||
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
|
||||
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
|
||||
var repoVolume string
|
||||
if empty {
|
||||
// Scratch spawn: no repo, no shared workspace — a blank slate.
|
||||
repoVolume = ""
|
||||
} else {
|
||||
repoVolume = volumeRepoPrefix + slug
|
||||
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if fresh {
|
||||
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
|
||||
// drop the half-seeded volume so the next spawn retries fresh
|
||||
// instead of silently booting into an empty workspace.
|
||||
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
|
||||
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
|
||||
}
|
||||
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
|
||||
}
|
||||
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
|
||||
@@ -548,15 +567,33 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
||||
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
|
||||
}
|
||||
|
||||
binds := []string{
|
||||
repoVolume + ":" + workspaceMount,
|
||||
volumeSessions + ":" + sessionsMount,
|
||||
volumePiCache + ":" + cacheMount,
|
||||
var binds []string
|
||||
if !empty {
|
||||
binds = append(binds, repoVolume+":"+workspaceMount)
|
||||
}
|
||||
binds = append(binds,
|
||||
volumeSessions+":"+sessionsMount,
|
||||
volumePiCache+":"+cacheMount,
|
||||
)
|
||||
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
|
||||
// spawned agents can use every model the catalog offers.
|
||||
// spawned agents can use every model the catalog offers. The host
|
||||
// models.json rides along when present: image fallbacks are baked at
|
||||
// build time and go stale the moment the dotfiles catalog changes.
|
||||
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
|
||||
binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro")
|
||||
if _, err := os.Stat(filepath.Join(hostAgent, "models.json")); err == nil {
|
||||
binds = append(binds, hostAgent+"/models.json:"+modelsMountTarget+":ro")
|
||||
}
|
||||
}
|
||||
// Shared spawn-pi mesh directory (node registry + AF_UNIX sockets),
|
||||
// read-write: each pi creates its own socket and node file. Sharing it
|
||||
// with the host puts container pi's on the same mesh as the host pi —
|
||||
// without this each container is an isolated island.
|
||||
if hostRuntime := os.Getenv(envHostPiRuntimeDir); hostRuntime != "" {
|
||||
mesh := filepath.Join(hostRuntime, "spawn-pi")
|
||||
if _, err := os.Stat(mesh); err == nil {
|
||||
binds = append(binds, mesh+":"+meshMountTarget)
|
||||
}
|
||||
}
|
||||
// Shared playwright browser cache (host path, read-only); the env var
|
||||
// below makes every playwright-based MCP use it instead of downloading.
|
||||
@@ -581,7 +618,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{
|
||||
|
||||
+86
-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", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -518,3 +518,88 @@ func TestSpawnerSecretsBinds(t *testing.T) {
|
||||
t.Fatalf("secrets binds missing: %v", binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerBindsHostModelsJSON(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
agent := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(agent, "models.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envHostPiAgentDir, agent)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
binds := creates[0].HostConfig.Binds
|
||||
want := agent + "/models.json:/root/.pi/agent/models.json:ro"
|
||||
ok := false
|
||||
for _, b := range binds {
|
||||
if b == want {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("models.json bind missing from %v", binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerBindsSpawnPiMesh(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
runtimeDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(runtimeDir, "spawn-pi"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(envHostPiRuntimeDir, runtimeDir)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
binds := creates[0].HostConfig.Binds
|
||||
want := filepath.Join(runtimeDir, "spawn-pi") + ":/root/.pi/spawn-pi"
|
||||
ok := false
|
||||
for _, b := range binds {
|
||||
if b == want {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("spawn-pi mesh bind missing from %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", "", "", false)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +614,16 @@ func (h *Hub) handleEvent(f frame) {
|
||||
log.Printf("hub: touch session %s: %v", f.sessionID, err)
|
||||
}
|
||||
}
|
||||
// Auto-title: first persisted user message on an unnamed session asks
|
||||
// the configured LLM for a short title (separate cheap completion; never
|
||||
// touches the agent conversation).
|
||||
if f.typ == evMessageEnd && h.needsAutoTitle(f) {
|
||||
sid := f.sessionID
|
||||
text := firstUserText(f.raw)
|
||||
if text != "" {
|
||||
go h.autoTitle(sid, text)
|
||||
}
|
||||
}
|
||||
// Track mid-turn state for the session-list activity pulse.
|
||||
if f.typ == evAgentStart || f.typ == evAgentSettled {
|
||||
busy := f.typ == evAgentStart
|
||||
|
||||
+7
-2
@@ -100,10 +100,15 @@ func (s *Server) handlePiConfigResync(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.Rename(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "move bake dir: "+err.Error())
|
||||
// copy, not rename: bakeDir (data volume) and bakeCtx (tmp) are
|
||||
// different mounts — rename(2) fails with EXDEV
|
||||
if err := copyTree(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "copy bake dir: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(bakeDir); err != nil {
|
||||
log.Printf("pi-config: remove bake dir after copy: %v", err)
|
||||
}
|
||||
|
||||
if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error())
|
||||
|
||||
+97
-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", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false); 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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
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", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1287,8 +1287,85 @@ 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"), "", false, "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", false)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerEmptyScratch(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", true)
|
||||
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))
|
||||
}
|
||||
binds := creates[0].HostConfig.Binds
|
||||
for _, b := range binds {
|
||||
if b == "lvmh-repo-group--project-ab12cd:/workspace" {
|
||||
t.Fatalf("empty spawn must not mount the repo volume: %v", binds)
|
||||
}
|
||||
}
|
||||
hasSessions := false
|
||||
for _, b := range binds {
|
||||
if b == "lvmh-sessions:/pi-sessions" {
|
||||
hasSessions = true
|
||||
}
|
||||
}
|
||||
if !hasSessions {
|
||||
t.Fatalf("sessions volume missing: %v", binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerEmptyScratchNoRepo(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Start with no repo: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 {
|
||||
t.Fatalf("creates = %d, want 1", len(creates))
|
||||
}
|
||||
for _, b := range creates[0].HostConfig.Binds {
|
||||
if b == "lvmh-sessions:/pi-sessions" || b == "lvmh-pi-cache:/root/.pi/agent/cache" {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("blank spawn must mount no repo volume: %v", creates[0].HostConfig.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,19 @@ fi
|
||||
# Bake the user's pi config (dotfiles) into the worker image context.
|
||||
bash "$(dirname "$0")/rsync-pi-agent.sh"
|
||||
|
||||
# A dotfiles-less checkout must not wipe the remote's baked pi config
|
||||
# (daemon catalog + worker image depend on it).
|
||||
PIAGENT_EXCLUDE=""
|
||||
if [ ! -f docker/pi-agent/settings.json ]; then
|
||||
echo "no local pi config — preserving remote docker/pi-agent"
|
||||
PIAGENT_EXCLUDE="--exclude /docker/pi-agent"
|
||||
fi
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude /.git --exclude /node_modules --exclude /web/node_modules \
|
||||
--exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \
|
||||
--exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \
|
||||
${PIAGENT_EXCLUDE} \
|
||||
./ "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
echo "building daemon image + worker image on $REMOTE..."
|
||||
|
||||
@@ -19,6 +19,9 @@ services:
|
||||
# docker.sock bind semantics: bind sources resolve on the HOST, so
|
||||
# this must be the HOST path of the pi config (auth.json etc.).
|
||||
LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/alarm/.dotfiles/pi/agent}
|
||||
# host pi runtime dir (spawn-pi mesh: nodes + sockets), shared rw with
|
||||
# spawned containers so host pi and container pi's form one mesh
|
||||
LVMH_HOST_PI_RUNTIME_DIR: ${LVMH_PI_RUNTIME_DIR:-/home/alarm/.pi}
|
||||
LVMH_SECRETS_DIR: /zdata/root/lvmh-secrets
|
||||
LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser
|
||||
LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright
|
||||
|
||||
+48
-3
@@ -3,8 +3,12 @@
|
||||
// web prompts through pi.sendUserMessage. This process just hosts the session.
|
||||
// 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 { copyFileSync, existsSync } from "node:fs";
|
||||
import {
|
||||
createAgentSession,
|
||||
ModelRuntime,
|
||||
SessionManager,
|
||||
} 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 +61,48 @@ process.on("unhandledRejection", (err) => {
|
||||
|
||||
try {
|
||||
await runRepoSetup();
|
||||
const { session } = await createAgentSession();
|
||||
// Dotfiles-less bakes ship only models.json.fallback: promote it so the
|
||||
// registry always resolves the zai provider (glm) instead of "not found".
|
||||
const piAgent = "/root/.pi/agent";
|
||||
if (!existsSync(`${piAgent}/models.json`) && existsSync(`${piAgent}/models.json.fallback`)) {
|
||||
copyFileSync(`${piAgent}/models.json.fallback`, `${piAgent}/models.json`);
|
||||
console.error("[lvmh-bridge] promoted models.json.fallback -> models.json");
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// Fresh session per spawn: without an explicit sessionManager the SDK
|
||||
// picks up an existing session file for the cwd — sharing one transcript
|
||||
// (and its saved model) across every spawn of the repo. A dedicated dir
|
||||
// per daemon session id keeps spawns isolated and LVMH_MODEL authoritative.
|
||||
const sessionRoot = process.env.PI_SESSION_DIR ?? "/pi-sessions";
|
||||
const sessionDir = `${sessionRoot}/${process.env.LVMH_SESSION_ID ?? "default"}`;
|
||||
const sessionManager = SessionManager.create(process.cwd(), sessionDir);
|
||||
const { session } = await createAgentSession(
|
||||
model === undefined ? { sessionManager } : { model, sessionManager },
|
||||
);
|
||||
// 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.
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"reasoning": true,
|
||||
"contextWindow": 1000000,
|
||||
"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }
|
||||
},
|
||||
{
|
||||
"id": "glm-5.3-flash",
|
||||
"name": "GLM-5.3 Flash",
|
||||
"reasoning": true,
|
||||
"contextWindow": 1000000,
|
||||
"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+356
-16
@@ -5,7 +5,10 @@ import type { ChatMessage, ToolState } from "./derive";
|
||||
import ChatStream, {
|
||||
Bubble,
|
||||
TypingIndicator,
|
||||
lineDiff,
|
||||
renderMarkdown,
|
||||
toolArgsEntries,
|
||||
toolSummaryText,
|
||||
} from "./ChatStream";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
@@ -35,6 +38,8 @@ const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
function stream(p: {
|
||||
messages: ChatMessage[];
|
||||
busy: boolean;
|
||||
queued?: string[];
|
||||
unqueue?: (index: number) => void;
|
||||
hasOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onOlder?: () => void;
|
||||
@@ -47,6 +52,8 @@ function stream(p: {
|
||||
messages={p.messages}
|
||||
tools={new Map()}
|
||||
busy={p.busy}
|
||||
queued={p.queued ?? []}
|
||||
unqueue={p.unqueue ?? (() => undefined)}
|
||||
hasOlder={p.hasOlder ?? false}
|
||||
loadingOlder={p.loadingOlder ?? false}
|
||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||
@@ -60,10 +67,7 @@ function stream(p: {
|
||||
describe("Bubble", () => {
|
||||
it("renders plain text per role class", () => {
|
||||
const { container } = render(
|
||||
<Bubble
|
||||
msg={msg({ role: "user", text: "hi there" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||
expect(container.textContent).toContain("hi there");
|
||||
@@ -91,10 +95,7 @@ describe("Bubble", () => {
|
||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||
const long = `${"x".repeat(200)}`;
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: long })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />,
|
||||
);
|
||||
const details = screen
|
||||
.getByText("result")
|
||||
@@ -154,11 +155,11 @@ describe("Bubble", () => {
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||
expect(screen.getByText("$ ls -la")).toBeInTheDocument();
|
||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||
|
||||
const summary = screen
|
||||
.getByText("🛠 bash")
|
||||
.getByText("$ ls -la")
|
||||
.closest("summary") as HTMLElement;
|
||||
const card = summary.closest("details") as HTMLDetailsElement;
|
||||
expect(card.open).toBe(false);
|
||||
@@ -187,9 +188,9 @@ describe("Bubble", () => {
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
||||
expect(screen.getByText("error")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
expect(screen.getByText("⋯")).toBeInTheDocument();
|
||||
expect(screen.getByText("✗")).toBeInTheDocument();
|
||||
expect(screen.getByText("✓")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tool call with no matching state renders no card", () => {
|
||||
@@ -253,9 +254,7 @@ describe("ChatStream", () => {
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
||||
);
|
||||
rerender(stream({ messages: [msg({ key: "a", text: "one" })], busy: false }));
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -761,3 +760,344 @@ describe("copy button", () => {
|
||||
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pretty tool args", () => {
|
||||
const tool = (args: string): ToolState => ({
|
||||
id: "t1",
|
||||
name: "bash",
|
||||
args,
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "out",
|
||||
});
|
||||
|
||||
it("bash command renders as the bare command, no JSON braces", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={{
|
||||
key: "k",
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
ts: 0,
|
||||
}}
|
||||
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".tool-label")?.textContent).toBe("$ ls -la");
|
||||
expect(document.querySelector(".tool-args")?.textContent).not.toContain(
|
||||
'"command"',
|
||||
);
|
||||
});
|
||||
|
||||
it("priority ordering puts command first even when not first in JSON", () => {
|
||||
const entries = toolArgsEntries('{"path":"a.go","command":"go build ./..."}');
|
||||
expect(entries[0]?.v).toBe("go build ./...");
|
||||
expect(entries[1]?.k).toBe("path");
|
||||
});
|
||||
|
||||
it("single string arg renders label-less; multi-field keeps labels", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={{
|
||||
key: "k",
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
ts: 0,
|
||||
}}
|
||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||
"$ src/main.ts",
|
||||
);
|
||||
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
||||
});
|
||||
|
||||
it("non-JSON args pass through raw", () => {
|
||||
const entries = toolArgsEntries("just text");
|
||||
expect(entries).toEqual([{ k: null, v: "just text" }]);
|
||||
});
|
||||
|
||||
it("JSON string arg unwraps", () => {
|
||||
const entries = toolArgsEntries('"plain string"');
|
||||
expect(entries).toEqual([{ k: null, v: "plain string" }]);
|
||||
});
|
||||
|
||||
it("non-string values are pretty JSON", () => {
|
||||
const entries = toolArgsEntries('{"offset":1}');
|
||||
expect(entries[0]?.v).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool summary + diff", () => {
|
||||
const tool = (args: string, name = "bash"): ToolState => ({
|
||||
id: "t1",
|
||||
name,
|
||||
args,
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
});
|
||||
it("bash summary shows the command", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$ ls -la")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read summary shows the path", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||
"📄 src/main.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("long summary is truncated with ellipsis", () => {
|
||||
const long: string = "x".repeat(300);
|
||||
expect(toolSummaryText(`{"command":"${long}"}`)).toBe(long);
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
|
||||
/>,
|
||||
);
|
||||
const summary = document.querySelector(".tool-label");
|
||||
expect(summary?.textContent?.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("edit tool renders a line diff of old/new text", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "edit", argsJson: "{}" }],
|
||||
})}
|
||||
tools={
|
||||
new Map([
|
||||
[
|
||||
"t1",
|
||||
tool(
|
||||
'{"path":"a.ts","edits":[{"oldText":"const a = 1;\\nconst b = 2;","newText":"const a = 3;\\nconst b = 2;"}]}',
|
||||
"edit",
|
||||
),
|
||||
],
|
||||
])
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".diff-del")?.textContent).toContain(
|
||||
"-const a = 1;",
|
||||
);
|
||||
expect(document.querySelector(".diff-add")?.textContent).toContain(
|
||||
"+const a = 3;",
|
||||
);
|
||||
expect(document.querySelector(".diff-ctx")?.textContent).toContain(
|
||||
" const b = 2;",
|
||||
);
|
||||
expect(document.querySelector(".tool-args")).toBeNull();
|
||||
});
|
||||
|
||||
it("write tool renders content as added lines", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
toolCalls: [{ id: "t1", name: "write", argsJson: "{}" }],
|
||||
})}
|
||||
tools={
|
||||
new Map([
|
||||
["t1", tool('{"path":"new.ts","content":"hello\\nworld"}', "write")],
|
||||
])
|
||||
}
|
||||
/>,
|
||||
);
|
||||
const adds = document.querySelectorAll(".diff-add");
|
||||
expect(adds).toHaveLength(2);
|
||||
expect(adds[0]?.textContent).toContain("+hello");
|
||||
expect(adds[1]?.textContent).toContain("+world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineDiff", () => {
|
||||
it("identical text is all context", () => {
|
||||
const d = lineDiff("a\nb", "a\nb");
|
||||
expect(d).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "ctx", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("single line change is del + add", () => {
|
||||
const d = lineDiff("a\nb\nc", "a\nx\nc");
|
||||
expect(d).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "del", text: "b" },
|
||||
{ kind: "add", text: "x" },
|
||||
{ kind: "ctx", text: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pure addition and deletion", () => {
|
||||
expect(lineDiff("a", "a\nb")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "add", text: "b" },
|
||||
]);
|
||||
expect(lineDiff("a\nb", "a")).toEqual([
|
||||
{ kind: "ctx", text: "a" },
|
||||
{ kind: "del", text: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("oversized blocks fall back to del-all + add-all", () => {
|
||||
const a = Array.from({ length: 401 }, (_, i) => `l${i}`).join("\n");
|
||||
const d = lineDiff(a, "x");
|
||||
expect(d.filter((l) => l.kind === "del")).toHaveLength(401);
|
||||
expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unified tool card", () => {
|
||||
const tool = (args: string): ToolState => ({
|
||||
id: "t1",
|
||||
name: "bash",
|
||||
args,
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
});
|
||||
|
||||
it("toolResult bubble is skipped when its tool card exists", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Bubble
|
||||
msg={msg({
|
||||
role: "assistant",
|
||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map([["t1", tool('{"command":"ls"}')]])}
|
||||
/>
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: "file1\nfile2", toolCallId: "t1" })}
|
||||
tools={new Map([["t1", tool('{"command":"ls"}')]])}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(1);
|
||||
expect(container.textContent).not.toContain("result");
|
||||
});
|
||||
|
||||
it("toolResult without tool state still renders a result card", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: "orphan output", toolCallId: "gone" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("orphan output").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("queued messages render as removable pending bubbles", async () => {
|
||||
const unqueue = vi.fn();
|
||||
const { rerender } = render(
|
||||
stream({
|
||||
messages: [msg({ role: "user", text: "hi" })],
|
||||
busy: true,
|
||||
queued: ["next msg"],
|
||||
unqueue,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("next msg")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText("Remove queued message"));
|
||||
expect(unqueue).toHaveBeenCalledWith(0);
|
||||
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ role: "user", text: "hi" })],
|
||||
busy: true,
|
||||
queued: [],
|
||||
unqueue,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("next msg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdown blocks", () => {
|
||||
it("headings render as h1-h6 with inline content", () => {
|
||||
const out = renderMarkdown("## Hello **world**");
|
||||
const h = out[0] as React.ReactElement<{ className: string }>;
|
||||
expect(h.type).toBe("h2");
|
||||
expect(h.props.className).toContain("md-h-2");
|
||||
});
|
||||
|
||||
it("--- renders a horizontal rule", () => {
|
||||
const out = renderMarkdown("above\n---\nbelow");
|
||||
expect(out.some((n) => (n as React.ReactElement).type === "hr")).toBe(true);
|
||||
});
|
||||
|
||||
it("> lines group into one blockquote", () => {
|
||||
const out = renderMarkdown("> quoted a\n> quoted b\nplain");
|
||||
const q = out.find(
|
||||
(n) => (n as React.ReactElement).type === "blockquote",
|
||||
) as React.ReactElement<{ children: React.ReactNode[] }>;
|
||||
expect(q).toBeDefined();
|
||||
expect(q.props.children.join("")).toContain("quoted a");
|
||||
expect(q.props.children.join("")).toContain("quoted b");
|
||||
});
|
||||
|
||||
it("- and * lines render as an unordered list", () => {
|
||||
const out = renderMarkdown("- one\n- two\n* three");
|
||||
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
|
||||
expect(ul.type).toBe("ul");
|
||||
expect(ul.props.children).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("1. lines render as an ordered list", () => {
|
||||
const out = renderMarkdown("1. first\n2. second");
|
||||
const ol = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
|
||||
expect(ol.type).toBe("ol");
|
||||
expect(ol.props.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("emphasis start is not a list marker", () => {
|
||||
const out = renderMarkdown("*bold start* to line");
|
||||
expect(out.some((n) => (n as React.ReactElement).type === "ul")).toBe(false);
|
||||
});
|
||||
|
||||
it("list items keep inline markdown", () => {
|
||||
const out = renderMarkdown("- has `code` and [l](https://x.io)");
|
||||
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
|
||||
const li = ul.props.children[0] as React.ReactElement<{
|
||||
children: React.ReactNode[];
|
||||
}>;
|
||||
expect(JSON.stringify(li.props.children)).toContain("code");
|
||||
expect(JSON.stringify(li.props.children)).toContain("https://x.io");
|
||||
});
|
||||
|
||||
it("fences still win over block markers", () => {
|
||||
const out = renderMarkdown("```\n- not a list\n# not a heading\n```");
|
||||
const pre = out[0] as React.ReactElement<{ children: string }>;
|
||||
expect(pre.type).toBe("pre");
|
||||
expect(pre.props.children).toContain("- not a list");
|
||||
});
|
||||
});
|
||||
|
||||
+379
-30
@@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200;
|
||||
const PIN_THRESHOLD_PX: number = 80;
|
||||
const FAB_THRESHOLD_PX: number = 400;
|
||||
const FENCE: string = "```";
|
||||
const HEADING_RE: RegExp = /^(#{1,6})\s+(.*)$/;
|
||||
const HR_RE: RegExp = /^(?:-{3,}|\*{3,}|_{3,})$/;
|
||||
const QUOTE_RE: RegExp = /^>\s?(.*)$/;
|
||||
const UL_RE: RegExp = /^[-*+]\s+(.*)$/;
|
||||
const OL_RE: RegExp = /^\d{1,9}[.)]\s+(.*)$/;
|
||||
const ENTER_KEY: string = "Enter";
|
||||
const ESCAPE_KEY: string = "Escape";
|
||||
const INLINE_RE: RegExp =
|
||||
@@ -79,11 +84,13 @@ function inlineNodes(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
|
||||
* [t](http…) links (http/https only). Plain segments keep the bubble's
|
||||
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
|
||||
/** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
|
||||
* lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
|
||||
* links (http/https only). Plain segments keep the bubble's pre-wrap.
|
||||
* Unterminated fences (streaming) render the tail as code. */
|
||||
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
const lines: string[] = text.split("\n");
|
||||
let plain: string[] = [];
|
||||
let code: string[] | null = null;
|
||||
let n = 0;
|
||||
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||
plain = [];
|
||||
}
|
||||
};
|
||||
for (const line of text.split("\n")) {
|
||||
// consecutive lines sharing a marker accumulate into one block element
|
||||
const collect = (
|
||||
re: RegExp,
|
||||
from: number,
|
||||
): { items: string[]; next: number } => {
|
||||
const items: string[] = [];
|
||||
let k: number = from;
|
||||
while (k < lines.length) {
|
||||
const raw: string | undefined = lines[k];
|
||||
if (raw === undefined) break;
|
||||
const m: RegExpMatchArray | null = raw.match(re);
|
||||
if (m === null) break;
|
||||
const item: string | undefined = m[1];
|
||||
if (item === undefined) break;
|
||||
items.push(item);
|
||||
k += 1;
|
||||
}
|
||||
return { items, next: k };
|
||||
};
|
||||
let i: number = 0;
|
||||
while (i < lines.length) {
|
||||
const line: string | undefined = lines[i];
|
||||
if (line === undefined) break;
|
||||
if (line.trimStart().startsWith(FENCE)) {
|
||||
if (code === null) {
|
||||
flushPlain();
|
||||
@@ -108,20 +137,86 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
||||
n += 1;
|
||||
code = null;
|
||||
}
|
||||
} else if (code !== null) {
|
||||
code.push(line);
|
||||
} else {
|
||||
plain.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (code !== null) {
|
||||
code.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const heading: RegExpMatchArray | null = line.match(HEADING_RE);
|
||||
if (heading !== null) {
|
||||
flushPlain();
|
||||
const level: number = Math.min(heading[1]?.length ?? 1, 6);
|
||||
const Tag: keyof React.JSX.IntrinsicElements = `h${level}` as "h1";
|
||||
out.push(
|
||||
<Tag className={`md-h md-h-${level}`} key={`h-${n}`}>
|
||||
{inlineNodes(heading[2] ?? "", query, `h-${n}`)}
|
||||
</Tag>,
|
||||
);
|
||||
n += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (HR_RE.test(line.trim())) {
|
||||
flushPlain();
|
||||
out.push(<hr className="md-hr" key={`r-${n}`} />);
|
||||
n += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (QUOTE_RE.test(line)) {
|
||||
flushPlain();
|
||||
const q: { items: string[]; next: number } = collect(QUOTE_RE, i);
|
||||
out.push(
|
||||
<blockquote className="md-quote" key={`q-${n}`}>
|
||||
{inlineNodes(q.items.join("\n"), query, `q-${n}`)}
|
||||
</blockquote>,
|
||||
);
|
||||
n += 1;
|
||||
i = q.next;
|
||||
continue;
|
||||
}
|
||||
if (UL_RE.test(line)) {
|
||||
flushPlain();
|
||||
const l: { items: string[]; next: number } = collect(UL_RE, i);
|
||||
out.push(
|
||||
<ul className="md-list" key={`u-${n}`}>
|
||||
{l.items.map((item, k) => (
|
||||
<li key={k}>{inlineNodes(item, query, `u-${n}-${k}`)}</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
n += 1;
|
||||
i = l.next;
|
||||
continue;
|
||||
}
|
||||
if (OL_RE.test(line)) {
|
||||
flushPlain();
|
||||
const l: { items: string[]; next: number } = collect(OL_RE, i);
|
||||
out.push(
|
||||
<ol className="md-list md-list-ol" key={`o-${n}`}>
|
||||
{l.items.map((item, k) => (
|
||||
<li key={k}>{inlineNodes(item, query, `o-${n}-${k}`)}</li>
|
||||
))}
|
||||
</ol>,
|
||||
);
|
||||
n += 1;
|
||||
i = l.next;
|
||||
continue;
|
||||
}
|
||||
plain.push(line);
|
||||
i += 1;
|
||||
}
|
||||
if (code !== null) {
|
||||
if (code === null) {
|
||||
flushPlain();
|
||||
} else {
|
||||
out.push(
|
||||
<pre className="md-code" key={`c-${n}`}>
|
||||
{code.join("\n")}
|
||||
</pre>,
|
||||
);
|
||||
} else {
|
||||
flushPlain();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -143,6 +238,14 @@ function oneLine(text: string): string {
|
||||
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
|
||||
}
|
||||
|
||||
/** true when the standalone result bubble is skipped because its ToolCard
|
||||
* (which carries the output preview) is already rendered */
|
||||
function resultSkipped(m: ChatMessage, tools: Map<string, ToolState>): boolean {
|
||||
return (
|
||||
m.role === "toolResult" && m.toolCallId !== null && tools.has(m.toolCallId)
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }): React.ReactNode {
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
return (
|
||||
@@ -162,32 +265,254 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- pretty tool args ----------
|
||||
|
||||
// Keys shown first when present (most readable "what is this tool doing"
|
||||
// signal); the rest follow in their original order.
|
||||
const ARG_PRIORITY: string[] = [
|
||||
"command",
|
||||
"path",
|
||||
"file_path",
|
||||
"pattern",
|
||||
"url",
|
||||
"query",
|
||||
"content",
|
||||
"prompt",
|
||||
"task",
|
||||
"description",
|
||||
];
|
||||
|
||||
// Above this many lines per side, LCS is skipped and the whole old block is
|
||||
// rendered removed + the whole new block added.
|
||||
const DIFF_MAX_LINES: number = 400;
|
||||
|
||||
export interface ArgEntry {
|
||||
k: string | null;
|
||||
v: string;
|
||||
}
|
||||
|
||||
/** Parse a tool's args (JSON object, JSON string, or raw text) into labeled
|
||||
* display entries. A lone string entry renders without a label. */
|
||||
export function toolArgsEntries(argsText: string): ArgEntry[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(argsText);
|
||||
} catch {
|
||||
return [{ k: null, v: argsText }];
|
||||
}
|
||||
if (typeof parsed === "string") return [{ k: null, v: parsed }];
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
return [{ k: null, v: argsText }];
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
const ordered = [
|
||||
...ARG_PRIORITY.filter((k) => k in obj),
|
||||
...keys.filter((k) => !ARG_PRIORITY.includes(k)),
|
||||
];
|
||||
const entries: ArgEntry[] = [];
|
||||
for (const k of ordered) {
|
||||
const raw = obj[k];
|
||||
const v =
|
||||
typeof raw === "string"
|
||||
? raw
|
||||
: (JSON.stringify(raw, null, 2) ?? String(raw));
|
||||
entries.push({ k, v });
|
||||
}
|
||||
if (entries.length === 1 && entries[0] !== undefined)
|
||||
return [{ k: null, v: entries[0].v }];
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** One-line "what is this tool doing" for the collapsed summary line:
|
||||
* the highest-priority arg's value (bash → command, read/write/edit → path). */
|
||||
export function toolSummaryText(argsText: string): string {
|
||||
const first = toolArgsEntries(argsText)[0];
|
||||
return first === undefined ? "" : first.v;
|
||||
}
|
||||
|
||||
// ---------- edit/write diff view ----------
|
||||
|
||||
export interface DiffLine {
|
||||
kind: "ctx" | "add" | "del";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Line-level LCS diff between two text blocks. */
|
||||
export function lineDiff(a: string, b: string): DiffLine[] {
|
||||
const left: string[] = a.split("\n");
|
||||
const right: string[] = b.split("\n");
|
||||
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
|
||||
return [
|
||||
...left.map((text): DiffLine => ({ kind: "del", text })),
|
||||
...right.map((text): DiffLine => ({ kind: "add", text })),
|
||||
];
|
||||
const n: number = left.length;
|
||||
const m: number = right.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
Array.from({ length: m + 1 }, (): number => 0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const row: number[] | undefined = dp[i];
|
||||
if (row === undefined) continue;
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
const a: string | undefined = left[i];
|
||||
const b: string | undefined = right[j];
|
||||
row[j] =
|
||||
a !== undefined && a === b
|
||||
? (dp[i + 1]?.[j + 1] ?? 0) + 1
|
||||
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
|
||||
}
|
||||
}
|
||||
const out: DiffLine[] = [];
|
||||
let i: number = 0;
|
||||
let j: number = 0;
|
||||
while (i < n && j < m) {
|
||||
const a: string | undefined = left[i];
|
||||
const b: string | undefined = right[j];
|
||||
if (a !== undefined && a === b) {
|
||||
out.push({ kind: "ctx", text: a });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
|
||||
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||
i += 1;
|
||||
} else {
|
||||
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for (; i < n; i++) {
|
||||
const a: string | undefined = left[i];
|
||||
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||
}
|
||||
for (; j < m; j++) {
|
||||
const b: string | undefined = right[j];
|
||||
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface TextEdit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
function parseArgsObj(argsText: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const p: unknown = JSON.parse(argsText);
|
||||
return typeof p === "object" && p !== null && !Array.isArray(p)
|
||||
? (p as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Diff blocks for edit/write tool args; null when the tool is neither or
|
||||
* the args do not carry the expected fields. */
|
||||
export function toolDiffBlocks(
|
||||
name: string,
|
||||
argsText: string,
|
||||
): DiffLine[][] | null {
|
||||
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
|
||||
if (obj === null) return null;
|
||||
if (name === "write" && typeof obj.content === "string")
|
||||
return [
|
||||
obj.content
|
||||
.split("\n")
|
||||
.slice(0, DIFF_MAX_LINES)
|
||||
.map((text): DiffLine => ({ kind: "add", text })),
|
||||
];
|
||||
if (name !== "edit") return null;
|
||||
const edits: TextEdit[] = [];
|
||||
if (Array.isArray(obj.edits)) {
|
||||
for (const e of obj.edits) {
|
||||
if (
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
typeof (e as Record<string, unknown>).oldText === "string" &&
|
||||
typeof (e as Record<string, unknown>).newText === "string"
|
||||
)
|
||||
edits.push(e as TextEdit);
|
||||
}
|
||||
} else if (
|
||||
typeof obj.oldText === "string" &&
|
||||
typeof obj.newText === "string"
|
||||
) {
|
||||
edits.push({ oldText: obj.oldText, newText: obj.newText });
|
||||
}
|
||||
return edits.length === 0
|
||||
? null
|
||||
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
|
||||
}
|
||||
|
||||
const DIFF_MARK: Record<DiffLine["kind"], string> = {
|
||||
add: "+",
|
||||
del: "-",
|
||||
ctx: " ",
|
||||
};
|
||||
|
||||
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
|
||||
return (
|
||||
<pre className="diff">
|
||||
{lines.map((l, i) => (
|
||||
<span key={i} className={`diff-line diff-${l.kind}`}>
|
||||
{DIFF_MARK[l.kind] + l.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Well-known tools get a compact glyph + their primary arg instead of
|
||||
// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …).
|
||||
const TOOL_ICON: Record<string, string> = {
|
||||
bash: "$",
|
||||
read: "📄",
|
||||
edit: "✎",
|
||||
write: "📝",
|
||||
};
|
||||
|
||||
function ToolCard({ tool }: { tool: ToolState }) {
|
||||
const status: string = tool.running
|
||||
? "running…"
|
||||
: tool.isError
|
||||
? "error"
|
||||
: "done";
|
||||
const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
|
||||
const statusClass: string = tool.isError
|
||||
? "tool-status-err"
|
||||
: "tool-status-ok";
|
||||
: tool.running
|
||||
? "tool-status-run"
|
||||
: "tool-status-ok";
|
||||
const summary: string = oneLine(toolSummaryText(tool.args));
|
||||
const icon: string | undefined = TOOL_ICON[tool.name];
|
||||
const label: string =
|
||||
icon === undefined ? tool.name : `${icon} ${summary}`.trim();
|
||||
const rest: string = icon === undefined ? summary : "";
|
||||
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
|
||||
return (
|
||||
<details className="tool-card">
|
||||
<summary>
|
||||
<span className="tool-name">🛠 {tool.name}</span>
|
||||
<span className={tool.running ? "" : statusClass}>
|
||||
{tool.running ? "working…" : status}
|
||||
</span>
|
||||
<span className="tool-label">{label}</span>
|
||||
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
|
||||
<span className={`tool-status ${statusClass}`}>{status}</span>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
{diffBlocks === null ? (
|
||||
<div className="tool-args">
|
||||
{toolArgsEntries(tool.args).map((e, i) => (
|
||||
<div key={e.k ?? i} className="tool-arg">
|
||||
{e.k !== null && <span className="arg-k">{e.k}</span>}
|
||||
<pre className="arg-v">{e.v}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tool-diffs">
|
||||
{diffBlocks.map((b, i) => (
|
||||
<DiffBlock key={i} lines={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>args</strong>
|
||||
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>
|
||||
{tool.args}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>result</strong>
|
||||
<span className="arg-k">output</span>
|
||||
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{tool.preview}
|
||||
</pre>
|
||||
@@ -238,6 +563,9 @@ export function Bubble({
|
||||
if (t !== undefined) msgTools.push(t);
|
||||
}
|
||||
}
|
||||
// the ToolCard above already carries this result (output preview): skip
|
||||
// the duplicate standalone "result" bubble when the tool state exists
|
||||
if (resultSkipped(msg, tools)) return null;
|
||||
const copyable: boolean =
|
||||
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
|
||||
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
|
||||
@@ -293,6 +621,10 @@ interface Props {
|
||||
messages: ChatMessage[];
|
||||
tools: Map<string, ToolState>;
|
||||
busy: boolean;
|
||||
/** messages queued client-side while the agent runs; sent on settle */
|
||||
queued: string[];
|
||||
/** drop a queued message before it is sent (the ✕ on a queued bubble) */
|
||||
unqueue: (index: number) => void;
|
||||
/** an older page exists beyond the loaded window (B1) */
|
||||
hasOlder: boolean;
|
||||
loadingOlder: boolean;
|
||||
@@ -308,6 +640,8 @@ export default function ChatStream({
|
||||
messages,
|
||||
tools,
|
||||
busy,
|
||||
queued,
|
||||
unqueue,
|
||||
hasOlder,
|
||||
loadingOlder,
|
||||
onLoadOlder,
|
||||
@@ -361,9 +695,9 @@ export default function ChatStream({
|
||||
const q: string = query.trim().toLowerCase();
|
||||
if (q.length === 0) return [];
|
||||
return messages
|
||||
.filter((m) => searchHaystack(m).includes(q))
|
||||
.filter((m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q))
|
||||
.map((m) => m.key);
|
||||
}, [messages, query]);
|
||||
}, [messages, tools, query]);
|
||||
|
||||
// live events can shrink the match set under the cursor
|
||||
useEffect(() => {
|
||||
@@ -426,6 +760,21 @@ export default function ChatStream({
|
||||
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
||||
</div>
|
||||
))}
|
||||
{queued.map((text, i) => (
|
||||
<div className="bubble-row user" key={`q-${i}-${text}`}>
|
||||
<div className="bubble queued-bubble">
|
||||
<div className="queued-line">{text}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn queued-remove"
|
||||
aria-label="Remove queued message"
|
||||
onClick={() => unqueue(i)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1692,3 +1692,80 @@ describe("ChatView ⋯ menu and timestamps", () => {
|
||||
expect(container.querySelectorAll(".ts")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("document title", () => {
|
||||
it("chat view claims title with session label and activity", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
|
||||
if (url.endsWith("/stats")) return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, sessionsCount: 0, onlineCount: 0 };
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
await waitFor(() => expect(document.title).toContain("worker"));
|
||||
expect(document.title).toContain("lvmh");
|
||||
});
|
||||
|
||||
it("busy + running tool shows the tool name in the title", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.startsWith("http://srv/api/sessions/s1/events")) {
|
||||
seq = 0;
|
||||
return [...historyEvents(), ev("agent_start"), ev("tool_execution_start", { toolCallId: "tc9", toolName: "bash" })];
|
||||
}
|
||||
if (url.endsWith("/stats")) return { turns: 0, inputTokens: 0, outputTokens: 0, totalCost: 0, sessionsCount: 0, onlineCount: 0 };
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
await waitFor(() => expect(document.title).toContain("bash"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView message queue", () => {
|
||||
it("Enter while busy queues instead of sending; flushed on settle", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) =>
|
||||
init?.method === "POST" ? { ok: true } : [],
|
||||
);
|
||||
renderChat(makeStore());
|
||||
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||
const promptCalls = (): number =>
|
||||
fetchMock.mock.calls.filter(([u]) =>
|
||||
String(u).endsWith("/api/sessions/s1/prompt"),
|
||||
).length;
|
||||
|
||||
push([ev("agent_start")]); // busy
|
||||
await userEvent.type(ta, "queued hello");
|
||||
fireEvent.keyDown(ta, { key: "Enter" });
|
||||
|
||||
// not sent yet; visible as a pending bubble
|
||||
expect(promptCalls()).toBe(0);
|
||||
expect(await screen.findByText("queued hello")).toBeInTheDocument();
|
||||
|
||||
// settled → flush
|
||||
push([ev("agent_settled")]);
|
||||
await vi.waitFor(() => expect(promptCalls()).toBe(1));
|
||||
});
|
||||
|
||||
it("queued bubble can be removed before it is sent", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) =>
|
||||
init?.method === "POST" ? { ok: true } : [],
|
||||
);
|
||||
renderChat(makeStore());
|
||||
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||
const promptCalls = (): number =>
|
||||
fetchMock.mock.calls.filter(([u]) =>
|
||||
String(u).endsWith("/api/sessions/s1/prompt"),
|
||||
).length;
|
||||
|
||||
push([ev("agent_start")]);
|
||||
await userEvent.type(ta, "do not send");
|
||||
fireEvent.keyDown(ta, { key: "Enter" });
|
||||
expect(await screen.findByText("do not send")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Remove queued message"));
|
||||
|
||||
push([ev("agent_settled")]);
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.queryByText("do not send")).toBeNull(),
|
||||
);
|
||||
expect(promptCalls()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+109
-52
@@ -8,6 +8,7 @@ import type {
|
||||
SetModelBody,
|
||||
} from "./protocol";
|
||||
import { EventType, Route } from "./protocol";
|
||||
import { useTitle } from "./title";
|
||||
import { ApiError, errMessage, fetchJson } from "./api";
|
||||
import {
|
||||
deriveChat,
|
||||
@@ -57,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;
|
||||
@@ -76,6 +86,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
const [loadError, setLoadError] = useState<string>("");
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
const [sending, setSending] = useState<boolean>(false);
|
||||
// messages waiting for the current run to settle (sent one per idle window)
|
||||
const [queued, setQueued] = useState<string[]>([]);
|
||||
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
|
||||
const [hasOlder, setHasOlder] = useState<boolean>(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
|
||||
@@ -130,15 +142,11 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
(incoming: EventFrame[]): void => {
|
||||
setEvents((prev) => {
|
||||
const merged = mergeEvents(prev, incoming);
|
||||
lastSeqRef.current = Math.max(
|
||||
lastSeqRef.current,
|
||||
lastPersistedSeq(merged),
|
||||
);
|
||||
lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged));
|
||||
return merged;
|
||||
});
|
||||
// a finished run means usage changed server-side
|
||||
if (incoming.some((e) => e.type === EventType.AgentSettled))
|
||||
refreshStats();
|
||||
if (incoming.some((e) => e.type === EventType.AgentSettled)) refreshStats();
|
||||
},
|
||||
[refreshStats],
|
||||
);
|
||||
@@ -156,6 +164,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
// leak into the new one
|
||||
setDraft("");
|
||||
setSending(false);
|
||||
setQueued([]);
|
||||
setSearchOpen(false);
|
||||
setMenuOpen(false);
|
||||
sentRef.current = [];
|
||||
@@ -210,6 +219,21 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
// session must never look busy (B2)
|
||||
const busy: boolean = chat.busy && session?.online !== false;
|
||||
|
||||
// Live tab title: session name + what the agent is doing right now.
|
||||
const label: string = session?.name ?? session?.repo ?? sessionId;
|
||||
const streaming: boolean = chat.messages.some((m) => m.streaming);
|
||||
const toolNow: string | undefined = [...chat.tools.values()]
|
||||
.filter((t) => t.running)
|
||||
.map((t) => t.name)[0];
|
||||
const titleClaim: string | null = busy
|
||||
? streaming
|
||||
? `${label} · writing…`
|
||||
: toolNow === undefined
|
||||
? `${label} · working…`
|
||||
: `${label} · ${toolNow}`
|
||||
: label;
|
||||
useTitle(titleClaim);
|
||||
|
||||
const minSeq: number = useMemo(
|
||||
() => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))),
|
||||
[events],
|
||||
@@ -250,9 +274,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
const t = e.target;
|
||||
if (
|
||||
t instanceof HTMLElement &&
|
||||
(t.tagName === "INPUT" ||
|
||||
t.tagName === "TEXTAREA" ||
|
||||
t.isContentEditable)
|
||||
(t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
@@ -286,30 +308,74 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0 || sending) return;
|
||||
setDraft("");
|
||||
setSending(true);
|
||||
const hist = sentRef.current;
|
||||
hist.push(text);
|
||||
if (hist.length > SENT_HISTORY_MAX) hist.shift();
|
||||
const postMessage = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await fetchJson(Route.SessionPrompt(sessionId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
// the message never reached the session: put it back (S6)
|
||||
setDraft(text);
|
||||
if (err instanceof ApiError && err.status === 409)
|
||||
pushToast("session offline");
|
||||
else pushToast(errMessage(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const rememberSent = (text: string): void => {
|
||||
const hist = sentRef.current;
|
||||
hist.push(text);
|
||||
if (hist.length > SENT_HISTORY_MAX) hist.shift();
|
||||
};
|
||||
|
||||
const send = async (): Promise<void> => {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0 || sending) return;
|
||||
setDraft("");
|
||||
// agent busy → queue; flushed when the run settles
|
||||
if (busy) {
|
||||
setQueued((q) => [...q, text]);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
rememberSent(text);
|
||||
try {
|
||||
// the message never reached the session: put it back (S6)
|
||||
if (!(await postMessage(text))) setDraft(text);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unqueue = (index: number): void => {
|
||||
setQueued((q) => q.filter((_, n) => n !== index));
|
||||
};
|
||||
|
||||
// drain the queue: one message per idle window — the gate only reopens
|
||||
// when the busy window actually opens (the POST resolving before the
|
||||
// agent_start event would otherwise fire the next message back-to-back)
|
||||
const flushingRef = useRef<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (busy) {
|
||||
flushingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (sending || queued.length === 0 || flushingRef.current) return;
|
||||
const text: string | undefined = queued[0];
|
||||
if (text === undefined) return;
|
||||
flushingRef.current = true;
|
||||
setQueued((q) => q.slice(1));
|
||||
rememberSent(text);
|
||||
void (async () => {
|
||||
// the message never reached the session: put it back (S6)
|
||||
if (!(await postMessage(text))) {
|
||||
flushingRef.current = false;
|
||||
setDraft(text);
|
||||
}
|
||||
})();
|
||||
}, [busy, sending, queued, postMessage]);
|
||||
|
||||
const abort = async (): Promise<void> => {
|
||||
try {
|
||||
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
|
||||
@@ -331,10 +397,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const pickModel = async (
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): Promise<void> => {
|
||||
const pickModel = async (provider: string, modelId: string): Promise<void> => {
|
||||
if (switching) return;
|
||||
setSwitching(true);
|
||||
try {
|
||||
@@ -421,8 +484,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
}
|
||||
// empty composer + history: ArrowUp recalls the last sent message
|
||||
if (e.key === ARROW_UP_KEY && draft.length === 0) {
|
||||
const last: string | undefined =
|
||||
sentRef.current[sentRef.current.length - 1];
|
||||
const last: string | undefined = sentRef.current[sentRef.current.length - 1];
|
||||
if (last !== undefined) {
|
||||
e.preventDefault();
|
||||
setDraft(last);
|
||||
@@ -528,17 +590,15 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{stats !== null &&
|
||||
(stats.inputTokens > 0 || stats.outputTokens > 0) && (
|
||||
<span
|
||||
className="usage-chip"
|
||||
title={`↑${stats.inputTokens.toLocaleString()} ↓${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
|
||||
>
|
||||
↑{formatTokens(stats.inputTokens)} ↓
|
||||
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $
|
||||
{stats.totalCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{stats !== null && (stats.inputTokens > 0 || stats.outputTokens > 0) && (
|
||||
<span
|
||||
className="usage-chip"
|
||||
title={`↑${stats.inputTokens.toLocaleString()} ↓${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
|
||||
>
|
||||
↑{formatTokens(stats.inputTokens)} ↓{formatTokens(stats.outputTokens)} ·{" "}
|
||||
{stats.turns} turns · ${stats.totalCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
"conn-dot",
|
||||
@@ -568,11 +628,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="chat-menu"
|
||||
role="menu"
|
||||
aria-label="Session actions"
|
||||
>
|
||||
<div className="chat-menu" role="menu" aria-label="Session actions">
|
||||
<button
|
||||
type="button"
|
||||
className="chat-menu-item"
|
||||
@@ -637,6 +693,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
messages={chat.messages}
|
||||
tools={chat.tools}
|
||||
busy={busy}
|
||||
queued={queued}
|
||||
unqueue={unqueue}
|
||||
hasOlder={hasOlder}
|
||||
loadingOlder={loadingOlder}
|
||||
onLoadOlder={() => void loadOlder()}
|
||||
@@ -656,7 +714,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{busy ? (
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
className="abort-btn"
|
||||
@@ -665,17 +723,16 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
>
|
||||
■ stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
aria-label="Send message"
|
||||
disabled={draft.trim().length === 0 || sending}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
aria-label={busy ? "Queue message" : "Send message"}
|
||||
disabled={draft.trim().length === 0 || sending}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -512,3 +512,15 @@ describe("busy indicator on cards", () => {
|
||||
expect(pips).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions title", () => {
|
||||
it("overview shows active/working count in the tab title", () => {
|
||||
renderView({
|
||||
sessions: [
|
||||
session({ id: "a", name: "x", busy: true }),
|
||||
session({ id: "b", name: "y" }),
|
||||
],
|
||||
});
|
||||
expect(document.title).toContain("1 working");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import type { SessionListItem, StatsTotals } from "./protocol";
|
||||
import { Route } from "./protocol";
|
||||
import { fetchJson } from "./api";
|
||||
import { useTitle } from "./title";
|
||||
import { classNames, formatTokens, relativeTime } from "./store";
|
||||
|
||||
interface Props {
|
||||
@@ -143,6 +144,12 @@ export default function SessionsView({
|
||||
const active = sessions.filter((s) => s.online).sort(byActivity);
|
||||
const archived = sessions.filter((s) => !s.online).sort(byActivity);
|
||||
|
||||
// Tab title on the overview: activity summary (busy sessions first).
|
||||
const busyCount: number = active.filter((s) => s.busy).length;
|
||||
const titleClaim: string | null =
|
||||
active.length === 0 ? null : busyCount > 0 ? `${busyCount} working` : `${active.length} active`;
|
||||
useTitle(titleClaim);
|
||||
|
||||
const remove = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
+128
-21
@@ -1,9 +1,16 @@
|
||||
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";
|
||||
@@ -105,8 +112,7 @@ describe("SpawnView connect flow", () => {
|
||||
connected = true;
|
||||
return { username: "alice" };
|
||||
}
|
||||
if (url.endsWith("/api/gitlab/repos"))
|
||||
return [repo("g/one"), repo("g/two")];
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
@@ -180,9 +186,7 @@ describe("SpawnView repo picker", () => {
|
||||
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||
|
||||
const item = screen
|
||||
.getByText("g/beta")
|
||||
.closest(".repo-item") as HTMLElement;
|
||||
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
|
||||
fireEvent.keyDown(item, { key: "Enter" });
|
||||
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||
|
||||
@@ -194,9 +198,7 @@ describe("SpawnView repo picker", () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
const item = screen
|
||||
.getByText("g/alpha")
|
||||
.closest(".repo-item") as HTMLElement;
|
||||
const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement;
|
||||
|
||||
const enter = fireEvent.keyDown(item, { key: "Enter" });
|
||||
expect(enter).toBe(false); // preventDefault consumed: no page scroll
|
||||
@@ -234,7 +236,11 @@ describe("SpawnView repo picker", () => {
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
||||
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"select a repo above — or tick “Empty container” for a blank workspace",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +270,7 @@ describe("SpawnView spawn+poll", () => {
|
||||
repo: "g/proj",
|
||||
startedAt: 1,
|
||||
online: true,
|
||||
busy: false,
|
||||
busy: false,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
]
|
||||
@@ -352,8 +358,7 @@ describe("SpawnView spawn+poll", () => {
|
||||
return [];
|
||||
});
|
||||
const failingRefresh = makeStore({
|
||||
refresh: (): Promise<SessionListItem[]> =>
|
||||
Promise.reject(new Error("boom")),
|
||||
refresh: (): Promise<SessionListItem[]> => Promise.reject(new Error("boom")),
|
||||
});
|
||||
render(tree(failingRefresh));
|
||||
await flush();
|
||||
@@ -531,10 +536,7 @@ describe("SpawnView repo images (registry)", () => {
|
||||
|
||||
const ok = screen.getByText("custom image");
|
||||
expect(ok.className).toContain("ok");
|
||||
expect(ok).toHaveAttribute(
|
||||
"title",
|
||||
"custom image lvmh-worker-alpha — built",
|
||||
);
|
||||
expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built");
|
||||
|
||||
const warn = screen.getByText("needs build");
|
||||
expect(warn.className).toContain("warn");
|
||||
@@ -560,8 +562,7 @@ describe("SpawnView repo images (registry)", () => {
|
||||
|
||||
const prepareCalls = fetchMock.mock.calls.filter(
|
||||
([u, i]) =>
|
||||
String(u).endsWith("/api/repos/g/alpha/prepare") &&
|
||||
i?.method === "POST",
|
||||
String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST",
|
||||
);
|
||||
expect(prepareCalls).toHaveLength(1);
|
||||
expect(PUSH_TOAST).toHaveBeenCalledWith(
|
||||
@@ -613,8 +614,7 @@ describe("SpawnView repo images (registry)", () => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
|
||||
if (url.endsWith("/api/repos"))
|
||||
return jsonResponse({ error: "boom" }, 500);
|
||||
if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
@@ -667,3 +667,110 @@ 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"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty container spawn", () => {
|
||||
it("checkbox sends empty:true and resets after", async () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
bodies.push(String(init.body));
|
||||
return { sessionId: "e1", imageUsed: "lvmh-worker:latest" };
|
||||
}
|
||||
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 cb = screen.getByRole("checkbox");
|
||||
fireEvent.click(cb);
|
||||
expect(cb).toBeChecked();
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(bodies[0]).toContain('"empty":true');
|
||||
});
|
||||
|
||||
it("blank container: spawn without selecting a repo", async () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
bodies.push(String(init.body));
|
||||
return { sessionId: "e2", imageUsed: "lvmh-worker:latest" };
|
||||
}
|
||||
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();
|
||||
|
||||
// no repo selected: Spawn disabled
|
||||
const spawnBtn = screen.getByLabelText(
|
||||
"Spawn container",
|
||||
) as HTMLButtonElement;
|
||||
expect(spawnBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(spawnBtn.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(spawnBtn);
|
||||
await flush();
|
||||
expect(bodies[0]).toContain('"empty":true');
|
||||
expect(bodies[0]).toContain('"repo":""');
|
||||
expect(bodies[0]).not.toContain('"branch"');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-22
@@ -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)
|
||||
@@ -39,9 +41,7 @@ function SpawnSteps({ state }: { state: string }): React.ReactNode {
|
||||
{SPAWN_STEPS.map((step, i) => (
|
||||
<span
|
||||
key={step}
|
||||
className={
|
||||
idx > i ? "step done" : idx === i ? "step current" : "step"
|
||||
}
|
||||
className={idx > i ? "step done" : idx === i ? "step current" : "step"}
|
||||
title={step}
|
||||
/>
|
||||
))}
|
||||
@@ -59,10 +59,23 @@ 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 [empty, setEmpty] = useState<boolean>(false);
|
||||
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);
|
||||
|
||||
@@ -152,15 +165,19 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
r.path.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// spawn is only reachable from the Spawn button, which is disabled until a
|
||||
// repo is selected.
|
||||
// spawn is reachable with a repo selected, or with the blank-container
|
||||
// checkbox and no repo (empty workspace).
|
||||
const spawn = async (): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = {
|
||||
repo: selected!.path,
|
||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||
repo: selected?.path ?? "",
|
||||
...(selected !== null && branch.trim().length > 0
|
||||
? { branch: branch.trim() }
|
||||
: {}),
|
||||
...(model.length > 0 ? { model } : {}),
|
||||
...(empty ? { empty: true } : {}),
|
||||
};
|
||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||
method: "POST",
|
||||
@@ -190,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
const list = await store.refresh();
|
||||
const s = list.find((x) => x.id === sessionId);
|
||||
if (s !== undefined && s.online) {
|
||||
if (timerRef.current !== null)
|
||||
window.clearInterval(timerRef.current);
|
||||
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
navigate(`/s/${sessionId}`);
|
||||
}
|
||||
@@ -219,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
job.message !== undefined && job.message.length > 0
|
||||
? ` (${job.message})`
|
||||
: "";
|
||||
return `${job.repo}: ${job.state}${detail}`;
|
||||
const label = job.repo.length > 0 ? job.repo : "blank container";
|
||||
return `${label}: ${job.state}${detail}`;
|
||||
}
|
||||
return "waiting for session to come online…";
|
||||
};
|
||||
@@ -229,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<div className="page">
|
||||
<h1>Spawn</h1>
|
||||
<p className="empty">
|
||||
{error.length > 0
|
||||
? `gitlab status failed: ${error}`
|
||||
: "checking gitlab…"}
|
||||
{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -321,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="repo-path">{r.path}</div>
|
||||
<div className="repo-meta">
|
||||
default {r.defaultBranch} ·{" "}
|
||||
{r.lastActivityAt.slice(0, 10)}
|
||||
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
{reg !== undefined && (
|
||||
@@ -347,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// let the native button handle Enter/Space
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
⚡ Prepare
|
||||
@@ -373,24 +386,58 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={selected === null || busy}
|
||||
disabled={(selected === null && !empty) || busy}
|
||||
aria-label="Spawn container"
|
||||
onClick={() => void spawn()}
|
||||
>
|
||||
{busy ? "Spawning…" : "Spawn"}
|
||||
</button>
|
||||
</div>
|
||||
{selected === null && (
|
||||
{selected === null && !empty && (
|
||||
<p className="repo-meta" style={{ marginTop: 8 }}>
|
||||
select a repo above
|
||||
select a repo above — or tick “Empty container” for a blank workspace
|
||||
</p>
|
||||
)}
|
||||
{selected !== null && (
|
||||
<p className="repo-meta" style={{ marginTop: 8 }}>
|
||||
Will use:{" "}
|
||||
{registration(selected.path)?.image ?? "base worker image"}
|
||||
Will use: {registration(selected.path)?.image ?? "base worker image"}
|
||||
</p>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
<select
|
||||
aria-label="Initial model"
|
||||
value={model}
|
||||
disabled={selected === null && !empty}
|
||||
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>
|
||||
<label
|
||||
className="repo-meta"
|
||||
style={{ marginTop: 8, display: "flex", gap: 6, alignItems: "center" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={empty}
|
||||
onChange={(e) => setEmpty(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
Empty container (no repo — blank workspace)
|
||||
</label>
|
||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
+185
-12
@@ -25,8 +25,7 @@
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter,
|
||||
sans-serif;
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -687,6 +686,27 @@ a:hover {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bubble .tool-card summary .tool-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.bubble .tool-card summary .tool-summary {
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.bubble .tool-card summary .tool-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bubble .tool-card .tool-body {
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -708,6 +728,9 @@ a:hover {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-status-run {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
details.thinking {
|
||||
margin-bottom: 10px;
|
||||
@@ -754,6 +777,48 @@ details.thinking .thinking-body {
|
||||
overflow-x: auto;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.bubble .md-h {
|
||||
margin: 14px 0 6px;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
.bubble .md-h:first-child {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.bubble .md-h-1 {
|
||||
font-size: 1.35em;
|
||||
}
|
||||
.bubble .md-h-2 {
|
||||
font-size: 1.22em;
|
||||
}
|
||||
.bubble .md-h-3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.bubble .md-h-4,
|
||||
.bubble .md-h-5,
|
||||
.bubble .md-h-6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
.bubble .md-hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
.bubble .md-quote {
|
||||
margin: 8px 0;
|
||||
padding: 2px 0 2px 12px;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.bubble .md-list {
|
||||
margin: 8px 0;
|
||||
padding-left: 22px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.bubble .md-list li {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.bubble code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
@@ -1367,6 +1432,26 @@ mark.hit {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.chat-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
padding: 8px 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-header .title {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-header .model-chip span:first-child {
|
||||
display: inline-block;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.chat-header .usage-chip {
|
||||
display: none;
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -1433,16 +1518,104 @@ mark.hit {
|
||||
/* ---------- busy activity pip ---------- */
|
||||
|
||||
.busy-pip {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: busy-pulse 1.1s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: busy-pulse 1.1s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes busy-pulse {
|
||||
0%, 100% { transform: scale(0.55); opacity: 0.45; box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5); }
|
||||
50% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 5px rgba(108, 140, 255, 0); }
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.55);
|
||||
opacity: 0.45;
|
||||
box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 5px rgba(108, 140, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- pretty tool args ---------- */
|
||||
|
||||
.tool-args {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tool-arg {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.arg-k {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.arg-v {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- edit/write diff ---------- */
|
||||
|
||||
.tool-diffs {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.diff {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-veil);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.diff-line {
|
||||
display: block;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.diff-del {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.diff-add {
|
||||
color: var(--ok);
|
||||
background: rgba(52, 211, 153, 0.08);
|
||||
}
|
||||
.diff-ctx {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---------- queued (pending) messages ---------- */
|
||||
|
||||
.queued-bubble {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
.queued-line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.queued-remove {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Live document-title state: the deepest mounted view pushes its strip;
|
||||
// when it unmounts the title falls back to the previous claimant.
|
||||
let base = "lvmh";
|
||||
let current: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
document.title = current === null ? base : `${current} — ${base}`;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** Set the app-wide base name (kept when no view claims a title). */
|
||||
export function setBaseTitle(name: string): void {
|
||||
base = name;
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Claim the document title while the calling view is mounted. */
|
||||
export function useTitle(claim: string | null): void {
|
||||
useEffect(() => {
|
||||
current = claim;
|
||||
emit();
|
||||
return () => {
|
||||
if (current === claim) {
|
||||
current = null;
|
||||
emit();
|
||||
}
|
||||
};
|
||||
}, [claim]);
|
||||
}
|
||||
|
||||
/** Force-refresh consumers (used after setBaseTitle). */
|
||||
export function subscribeTitle(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
Reference in New Issue
Block a user