Compare commits

..
16 Commits
Author SHA1 Message Date
buenosair 4755f6d1e3 feat(spawn): share host spawn-pi mesh + models.json with spawned containers
- bind $LVMH_HOST_PI_RUNTIME_DIR/spawn-pi -> /root/.pi/spawn-pi (rw) so
  container pi's join the host pi's node mesh (spawn_pi/list_pi_nodes/
  send_pi_message now reach across the host/container boundary)
- bind host models.json (when present) over the stale image-baked
  fallback so newly added catalog models resolve in spawns
2026-09-07 13:21:49 +00:00
buenosair 6ef27e5cd6 feat(models): add glm-5.3-flash to worker model fallback catalog 2026-09-07 12:09:56 +00:00
buenosair b62e4e717a fix: glm not found in dotfiles-less workers — bridge promotes models.json.fallback at startup (the fallback was never read by anything) 2026-09-04 07:59:54 +00:00
buenosair 310127cf7a fix(model selection): deploys from dotfiles-less machines no longer wipe the remote baked pi config (deploy.sh excludes docker/pi-agent); resync endpoint fixed for in-container runs (bash/rsync/python3/npm in daemon image, cross-device copy instead of rename) 2026-09-02 11:51:47 +00:00
buenosair 09fc72556d feat: markdown block parsing in LLM output — headings, lists (ul/ol), blockquotes, hr on top of fences + inline; 300 tests green 2026-09-02 09:49:56 +00:00
buenosair 0f245c9ee2 feat: start on a blank container — spawn with empty:true and no repo (skip clone, default worker image); Spawn UI enables blank spawn without repo selection 2026-09-02 09:24:54 +00:00
buenosair c8cde3ff89 style: reformat ChatStream/ChatView (line-width normalization) from review pass 2026-09-01 15:21:42 +00:00
buenosair 7ff9f77f40 feat: unified tool cards (glyph + arg, call+result in one card, ✓/✗ status), client-side message queue (queue while busy, flush on settle, removable pending bubbles), mobile chat-header wrap 2026-09-01 14:14:52 +00:00
buenosair 5962b50c8d style: normalize formatting (tabs) in ChatStream tests + index.css from review pass 2026-09-01 13:52:03 +00:00
buenosair 3b799c69fb web: tool cards show what ran — summary line (bash command / file path) + edit/write line diffs (LCS, +/- coloring); 286 tests green 2026-09-01 13:17:54 +00:00
Raphael Westphal f5ee3a0f2d feat: empty-container spawn (scratch workspace, no repo volume); fix bridge session isolation — explicit SessionManager per spawn stops SDK resuming the repo's old session (which also overrode LVMH_MODEL) 2026-08-20 15:12:45 +02:00
Raphael Westphal bdfa61a8b2 web: pretty tool cards — args rendered as the real command/path (priority keys, label-less single field), output label; 276 tests green 2026-08-20 14:55:55 +02:00
Raphael Westphal 2d0839357d feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare) 2026-08-20 12:43:55 +02:00
Raphael Westphal 5292a69d66 fix(autotitle): disable GLM thinking + raise max_tokens — reasoning model burned the 24-token budget before emitting any text 2026-08-20 11:52:15 +02:00
Raphael Westphal 1b6bd54a74 feat: LLM auto-titles — daemon names unnamed sessions from their first user message via one cheap ZAI completion (no agent-turn pollution); flows into sidebar + tab title 2026-08-20 11:44:05 +02:00
Raphael Westphal 9d369f410e feat: live document title — chat tab shows session name + activity (writing…/tool name/working…), overview shows busy/active counts; 268 tests, gates ≥95% 2026-08-20 11:19:21 +02:00
25 changed files with 2338 additions and 225 deletions
+3 -1
View File
@@ -10,7 +10,9 @@ COPY daemon/ ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/lvmh-daemon . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/lvmh-daemon .
FROM alpine:3.21 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 WORKDIR /app
COPY --from=build /out/lvmh-daemon /app/lvmh-daemon COPY --from=build /out/lvmh-daemon /app/lvmh-daemon
# Placeholder UI; replace via build (cp web/dist daemon/webdist) or mount at # Placeholder UI; replace via build (cp web/dist daemon/webdist) or mount at
+57 -9
View File
@@ -17,6 +17,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// Route names, event query defaults and limits. // Route names, event query defaults and limits.
@@ -35,8 +36,9 @@ const (
maxBodyBytes int64 = 1 << 20 maxBodyBytes int64 = 1 << 20
webIndexFallback string = "index.html" webIndexFallback string = "index.html"
reposPathPrefix string = "/api/repos/" reposPathPrefix string = "/api/repos/"
reposPrepareSuffix string = "/prepare" reposPrepareSuffix string = "/prepare"
prepOpsBootWait time.Duration = 90 * time.Second
// opsPreparePrompt is sent to the ops control session by // opsPreparePrompt is sent to the ops control session by
// POST /api/repos/<repo>/prepare. // POST /api/repos/<repo>/prepare.
opsPreparePrompt string = "prepare %s: clone, build a worker image, register it" 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 { var body struct {
Repo string `json:"repo"` Repo string `json:"repo"`
Branch string `json:"branch"` Branch string `json:"branch"`
Model string `json:"model"`
Empty bool `json:"empty"`
} }
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return 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") writeError(w, http.StatusBadRequest, "repo must look like group/project")
return return
} }
@@ -377,7 +387,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid branch name") writeError(w, http.StatusBadRequest, "invalid branch name")
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -574,7 +590,9 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
path := envOr(envModelsFile, "") path := envOr(envModelsFile, "")
if path == "" { if path == "" {
path = defaultBakedModelsFile 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 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") writeError(w, http.StatusBadRequest, "repo must look like group/project")
return return
} }
if err := s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)); err != nil { // Fresh ops context per prepare: when ops is running, restart it (wiping
if errors.Is(err, ErrOffline) { // its transcript) so the prompt lands in a clean session — no history
writeError(w, http.StatusConflict, "ops session offline") // 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 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 return
} }
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+124
View File
@@ -575,3 +575,127 @@ func TestParseEnabledModels(t *testing.T) {
t.Fatal("invalid json must yield nil") 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)
}
}
+52 -9
View File
@@ -4,8 +4,12 @@ package main
// GET /api/repos built flags, POST /api/spawn imageUsed resolution. // GET /api/repos built flags, POST /api/spawn imageUsed resolution.
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -14,17 +18,46 @@ import (
// TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route // TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route
// delivers the prompt; without it the route 409s. // delivers the prompt; without it the route 409s.
func TestAPIPrepareRepoRoutesToOps(t *testing.T) { 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 auth := testToken
prepareURL := ts.URL + "/api/repos/g/p/prepare" 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 { if code, body := apiReq(t, http.MethodPost, prepareURL, auth, ""); code != http.StatusConflict {
t.Fatalf("prepare offline = %d %s, want 409", code, body) t.Fatalf("prepare offline = %d %s, want 409", code, body)
} }
// path validation // 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 { if code, _ := apiReq(t, http.MethodPost, ts.URL+path, auth, ""); code != http.StatusBadRequest {
t.Fatalf("POST %s = %d, want 400", path, code) t.Fatalf("POST %s = %d, want 400", path, code)
} }
@@ -33,7 +66,14 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
t.Fatal("prepare must require auth") 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) ws := dialAgent(t, ts)
if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil { if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil {
t.Fatalf("hello: %v", err) t.Fatalf("hello: %v", err)
@@ -49,17 +89,20 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
if prompt["type"] != evPrompt { if prompt["type"] != evPrompt {
t.Fatalf("prompt frame = %v", prompt) 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" want := "prepare g/p: clone, build a worker image, register it"
if prompt["message"] != want { if prompt["message"] != want {
t.Fatalf("prompt message = %q, want %q", 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) { func TestAPIRepoImagesBuiltFlag(t *testing.T) {
ts, f := newSpawnAPIServer(t) ts, f := newSpawnAPIServer(t)
auth := testToken auth := testToken
+161
View File
@@ -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
}
+250
View File
@@ -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
View File
@@ -47,11 +47,14 @@ const (
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
cacheMount string = "/root/.pi/agent/cache" cacheMount string = "/root/.pi/agent/cache"
authMountTarget string = "/root/.pi/agent/auth.json" authMountTarget string = "/root/.pi/agent/auth.json"
modelsMountTarget string = "/root/.pi/agent/models.json"
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR" envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
envHostPiRuntimeDir string = "LVMH_HOST_PI_RUNTIME_DIR"
envSecretsDir string = "LVMH_SECRETS_DIR" envSecretsDir string = "LVMH_SECRETS_DIR"
envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR" envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR"
envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR" envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR"
sshMountTarget string = "/root/.ssh" sshMountTarget string = "/root/.ssh"
meshMountTarget string = "/root/.pi/spawn-pi"
gitconfigMountTarget string = "/root/.gitconfig" gitconfigMountTarget string = "/root/.gitconfig"
workspaceMount string = "/workspace" workspaceMount string = "/workspace"
sessionsMount string = "/pi-sessions" 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. // branchRe guards spawn branch names against git option injection.
var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) 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). // validRepoPath accepts "group/project" style paths (at least two segments).
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) } 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. // 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) exists, err := s.imageExists(ctx)
if err != nil { if err != nil {
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err) 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() sessionID := newUUID()
s.setJob(sessionID, repo, stateCloning, "", "") 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 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. // 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) slug := repoSlug(repo)
lock := s.slugLock(slug) lock := s.slugLock(slug)
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil { // blank-container spawn: nothing to clone or update
s.setJob(sessionID, repo, stateError, "", err.Error()) if repo != "" {
return if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
} }
s.setJob(sessionID, repo, stateBuilding, "", "") s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(s.ctx); err != nil { if err := s.ensureImage(s.ctx); err != nil {
@@ -305,7 +314,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
return return
} }
s.setJob(sessionID, repo, stateCreating, "", "") 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 { if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
return return
@@ -364,7 +373,7 @@ func giteaTokenEnv(store *Store) string {
} }
// workerEnv assembles the env for a spawned worker container. // 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{ env := []string{
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey), envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
envToken + "=" + daemonToken, envToken + "=" + daemonToken,
@@ -379,6 +388,10 @@ func workerEnv(s *Spawner, repo, sessionID string) []string {
"PLAYWRIGHT_BROWSERS_PATH=/pw-browsers", "PLAYWRIGHT_BROWSERS_PATH=/pw-browsers",
envLVMHRepo + "=" + repo, 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. // Gitea token (write scope) so agents can push and open PRs.
if e := giteaTokenEnv(s.store); e != "" { if e := giteaTokenEnv(s.store); e != "" {
env = append(env, e) env = append(env, e)
@@ -515,7 +528,7 @@ func extractBuildError(body []byte) string {
// createAndStart provisions volumes, creates and starts the worker container. // createAndStart provisions volumes, creates and starts the worker container.
// A repo-registered custom image (see /api/repos) overrides the default // A repo-registered custom image (see /api/repos) overrides the default
// worker image; the ops agent builds and registers those. // 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) image := s.resolveImage(repo)
if image != imageRefWorker { if image != imageRefWorker {
exists, err := s.imageRefExists(ctx, image) 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) return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
} }
} }
repoVolume := volumeRepoPrefix + slug var repoVolume string
fresh, err := s.ensureRepoVolume(ctx, repoVolume) if empty {
if err != nil { // Scratch spawn: no repo, no shared workspace — a blank slate.
return "", "", err repoVolume = ""
} } else {
if fresh { repoVolume = volumeRepoPrefix + slug
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil { fresh, err := s.ensureRepoVolume(ctx, repoVolume)
// drop the half-seeded volume so the next spawn retries fresh if err != nil {
// instead of silently booting into an empty workspace. return "", "", err
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil { }
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr) 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 { 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) return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
} }
binds := []string{ var binds []string
repoVolume + ":" + workspaceMount, if !empty {
volumeSessions + ":" + sessionsMount, binds = append(binds, repoVolume+":"+workspaceMount)
volumePiCache + ":" + cacheMount,
} }
binds = append(binds,
volumeSessions+":"+sessionsMount,
volumePiCache+":"+cacheMount,
)
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so // 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 != "" { if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro") 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 // Shared playwright browser cache (host path, read-only); the env var
// below makes every playwright-based MCP use it instead of downloading. // 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{ cfg := &container.Config{
Image: image, Image: image,
Env: workerEnv(s, repo, sessionID), Env: workerEnv(s, repo, model, sessionID),
Labels: map[string]string{labelSession: sessionID}, Labels: map[string]string{labelSession: sessionID},
} }
hostCfg := &container.HostConfig{ hostCfg := &container.HostConfig{
+86 -1
View File
@@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) {
sec := t.TempDir() sec := t.TempDir()
t.Setenv(envSecretsDir, sec) t.Setenv(envSecretsDir, sec)
res, err := sp.Start(context.Background(), "group/project", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -518,3 +518,88 @@ func TestSpawnerSecretsBinds(t *testing.T) {
t.Fatalf("secrets binds missing: %v", binds) 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)
}
}
}
+10
View File
@@ -614,6 +614,16 @@ func (h *Hub) handleEvent(f frame) {
log.Printf("hub: touch session %s: %v", f.sessionID, err) 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. // Track mid-turn state for the session-list activity pulse.
if f.typ == evAgentStart || f.typ == evAgentSettled { if f.typ == evAgentStart || f.typ == evAgentSettled {
busy := f.typ == evAgentStart busy := f.typ == evAgentStart
+7 -2
View File
@@ -100,10 +100,15 @@ func (s *Server) handlePiConfigResync(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error()) writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error())
return return
} }
if err := os.Rename(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil { // copy, not rename: bakeDir (data volume) and bakeCtx (tmp) are
writeError(w, http.StatusInternalServerError, "move bake dir: "+err.Error()) // 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 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 { if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil {
writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error()) writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error())
+97 -20
View File
@@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
f := newFakeDocker() f := newFakeDocker()
sp, store := newTestSpawner(t, f) 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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
f.images = 0 // image absent → ensureImage must build f.images = 0 // image absent → ensureImage must build
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewSpawner: %v", err) 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") { if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
t.Fatalf("Start without dockerfile err = %v", err) t.Fatalf("Start without dockerfile err = %v", err)
} }
@@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) {
tc.setup(t, f) tc.setup(t, f)
} }
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
t.Fatal("slugLock must return distinct mutexes per slug") 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 { if err != nil {
t.Fatalf("Start 1: %v", err) 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 { if err != nil {
t.Fatalf("Start 2: %v", err) t.Fatalf("Start 2: %v", err)
} }
@@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
t.Fatalf("set token: %v", err) 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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
f.failArchive = true // CopyToContainer fails → seed fails f.failArchive = true // CopyToContainer fails → seed fails
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) 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. // because the client is already built; close the fake server instead.
sp.cli.Close() sp.cli.Close()
closeDocker(t, sp) 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) 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.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
f.failVolumeCreate = true f.failVolumeCreate = true
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) {
dead2 := openTestStore(t) dead2 := openTestStore(t)
_ = dead2.Close() _ = dead2.Close()
sp2.store = dead2 sp2.store = dead2
res, err := sp2.Start(context.Background(), "group/project", "") res, err := sp2.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
sp, _ := newTestSpawner(t, f) sp, _ := newTestSpawner(t, f)
f.failCreate = true f.failCreate = true
res, err := sp.Start(context.Background(), "group/project", "") res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
f.failCreate = false f.failCreate = false
f.failStart = true f.failStart = true
res2, err := sp.Start(context.Background(), "group/project", "") res2, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil { if err != nil {
t.Fatalf("Start 2: %v", err) t.Fatalf("Start 2: %v", err)
} }
@@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) {
f.images = 0 f.images = 0
f.failBuildHTTP = true // /build endpoint itself 500s f.failBuildHTTP = true // /build endpoint itself 500s
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
useFakeGit(t, fakeGitModeOK) useFakeGit(t, fakeGitModeOK)
f := newFakeDocker() f := newFakeDocker()
sp, _ := newTestSpawner(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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) {
t.Fatalf("set repo image: %v", err) 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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) {
t.Fatalf("set repo image: %v", err) 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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
t.Fatalf("delete repo image: %v", err) 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 { if err != nil {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
@@ -1287,8 +1287,85 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
t.Fatalf("set repo image: %v", err) t.Fatalf("set repo image: %v", err)
} }
sp.images0AndDead(t) 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") { if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err) 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)
}
}
+9
View File
@@ -22,10 +22,19 @@ fi
# Bake the user's pi config (dotfiles) into the worker image context. # Bake the user's pi config (dotfiles) into the worker image context.
bash "$(dirname "$0")/rsync-pi-agent.sh" 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 \ rsync -az --delete \
--exclude /.git --exclude /node_modules --exclude /web/node_modules \ --exclude /.git --exclude /node_modules --exclude /web/node_modules \
--exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \ --exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \
--exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \ --exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \
${PIAGENT_EXCLUDE} \
./ "$REMOTE:$REMOTE_DIR/" ./ "$REMOTE:$REMOTE_DIR/"
echo "building daemon image + worker image on $REMOTE..." echo "building daemon image + worker image on $REMOTE..."
+3
View File
@@ -19,6 +19,9 @@ services:
# docker.sock bind semantics: bind sources resolve on the HOST, so # docker.sock bind semantics: bind sources resolve on the HOST, so
# this must be the HOST path of the pi config (auth.json etc.). # 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} 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_SECRETS_DIR: /zdata/root/lvmh-secrets
LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser
LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright
+48 -3
View File
@@ -3,8 +3,12 @@
// web prompts through pi.sendUserMessage. This process just hosts the session. // 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). // Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { copyFileSync, existsSync } from "node:fs";
import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; 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_PATH = "/workspace/.lvmh/setup.sh";
const SETUP_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
@@ -57,7 +61,48 @@ process.on("unhandledRejection", (err) => {
try { try {
await runRepoSetup(); 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 // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
// bindExtensions the session_start event never fires, so the lvmh plugin // bindExtensions the session_start event never fires, so the lvmh plugin
// would never dial the daemon. // would never dial the daemon.
+7
View File
@@ -16,6 +16,13 @@
"reasoning": true, "reasoning": true,
"contextWindow": 1000000, "contextWindow": 1000000,
"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" } "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
View File
@@ -5,7 +5,10 @@ import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { import ChatStream, {
Bubble, Bubble,
TypingIndicator, TypingIndicator,
lineDiff,
renderMarkdown, renderMarkdown,
toolArgsEntries,
toolSummaryText,
} from "./ChatStream"; } from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage { function msg(partial: Partial<ChatMessage>): ChatMessage {
@@ -35,6 +38,8 @@ const tool = (p: Partial<ToolState>): ToolState => ({
function stream(p: { function stream(p: {
messages: ChatMessage[]; messages: ChatMessage[];
busy: boolean; busy: boolean;
queued?: string[];
unqueue?: (index: number) => void;
hasOlder?: boolean; hasOlder?: boolean;
loadingOlder?: boolean; loadingOlder?: boolean;
onOlder?: () => void; onOlder?: () => void;
@@ -47,6 +52,8 @@ function stream(p: {
messages={p.messages} messages={p.messages}
tools={new Map()} tools={new Map()}
busy={p.busy} busy={p.busy}
queued={p.queued ?? []}
unqueue={p.unqueue ?? (() => undefined)}
hasOlder={p.hasOlder ?? false} hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false} loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)} onLoadOlder={p.onOlder ?? (() => undefined)}
@@ -60,10 +67,7 @@ function stream(p: {
describe("Bubble", () => { describe("Bubble", () => {
it("renders plain text per role class", () => { it("renders plain text per role class", () => {
const { container } = render( const { container } = render(
<Bubble <Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />,
msg={msg({ role: "user", text: "hi there" })}
tools={new Map()}
/>,
); );
expect(container.querySelector(".bubble-row.user")).not.toBeNull(); expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there"); 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 () => { it("toolResult collapses to a one-line preview, expands to full text", async () => {
const long = `${"x".repeat(200)}`; const long = `${"x".repeat(200)}`;
render( render(
<Bubble <Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />,
msg={msg({ role: "toolResult", text: long })}
tools={new Map()}
/>,
); );
const details = screen const details = screen
.getByText("result") .getByText("result")
@@ -154,11 +155,11 @@ describe("Bubble", () => {
tools={tools} tools={tools}
/>, />,
); );
expect(screen.getByText("🛠 bash")).toBeInTheDocument(); expect(screen.getByText("$ ls -la")).toBeInTheDocument();
expect(screen.getByText("finished")).toBeInTheDocument(); expect(screen.getByText("finished")).toBeInTheDocument();
const summary = screen const summary = screen
.getByText("🛠 bash") .getByText("$ ls -la")
.closest("summary") as HTMLElement; .closest("summary") as HTMLElement;
const card = summary.closest("details") as HTMLDetailsElement; const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(false); expect(card.open).toBe(false);
@@ -187,9 +188,9 @@ describe("Bubble", () => {
tools={tools} tools={tools}
/>, />,
); );
expect(screen.getByText("working…")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
}); });
it("tool call with no matching state renders no card", () => { it("tool call with no matching state renders no card", () => {
@@ -253,9 +254,7 @@ describe("ChatStream", () => {
}), }),
); );
expect(container.querySelector(".typing")).toBeNull(); expect(container.querySelector(".typing")).toBeNull();
rerender( rerender(stream({ messages: [msg({ key: "a", text: "one" })], busy: false }));
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
);
expect(container.querySelector(".typing")).toBeNull(); expect(container.querySelector(".typing")).toBeNull();
}); });
@@ -761,3 +760,344 @@ describe("copy button", () => {
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); 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
View File
@@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80; const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400; const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```"; 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 ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape"; const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp = const INLINE_RE: RegExp =
@@ -79,11 +84,13 @@ function inlineNodes(
return out; return out;
} }
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*, /** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
* [t](http…) links (http/https only). Plain segments keep the bubble's * lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
* pre-wrap. Unterminated fences (streaming) render the tail as code. */ * 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[] { export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
const lines: string[] = text.split("\n");
let plain: string[] = []; let plain: string[] = [];
let code: string[] | null = null; let code: string[] | null = null;
let n = 0; let n = 0;
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
plain = []; 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 (line.trimStart().startsWith(FENCE)) {
if (code === null) { if (code === null) {
flushPlain(); flushPlain();
@@ -108,20 +137,86 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
n += 1; n += 1;
code = null; code = null;
} }
} else if (code !== null) { i += 1;
code.push(line); continue;
} else {
plain.push(line);
} }
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( out.push(
<pre className="md-code" key={`c-${n}`}> <pre className="md-code" key={`c-${n}`}>
{code.join("\n")} {code.join("\n")}
</pre>, </pre>,
); );
} else {
flushPlain();
} }
return out; return out;
} }
@@ -143,6 +238,14 @@ function oneLine(text: string): string {
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat; 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 { function CopyButton({ text }: { text: string }): React.ReactNode {
const [copied, setCopied] = useState<boolean>(false); const [copied, setCopied] = useState<boolean>(false);
return ( 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 }) { function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError const statusClass: string = tool.isError
? "tool-status-err" ? "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 ( return (
<details className="tool-card"> <details className="tool-card">
<summary> <summary>
<span className="tool-name">🛠 {tool.name}</span> <span className="tool-label">{label}</span>
<span className={tool.running ? "" : statusClass}> {rest.length > 0 && <span className="tool-summary">{rest}</span>}
{tool.running ? "working…" : status} <span className={`tool-status ${statusClass}`}>{status}</span>
</span>
</summary> </summary>
<div className="tool-body"> <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> <div>
<strong>args</strong> <span className="arg-k">output</span>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>
{tool.args}
</pre>
</div>
<div>
<strong>result</strong>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}> <pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
{tool.preview} {tool.preview}
</pre> </pre>
@@ -238,6 +563,9 @@ export function Bubble({
if (t !== undefined) msgTools.push(t); 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 = const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : ""; const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
@@ -293,6 +621,10 @@ interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
tools: Map<string, ToolState>; tools: Map<string, ToolState>;
busy: boolean; 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) */ /** an older page exists beyond the loaded window (B1) */
hasOlder: boolean; hasOlder: boolean;
loadingOlder: boolean; loadingOlder: boolean;
@@ -308,6 +640,8 @@ export default function ChatStream({
messages, messages,
tools, tools,
busy, busy,
queued,
unqueue,
hasOlder, hasOlder,
loadingOlder, loadingOlder,
onLoadOlder, onLoadOlder,
@@ -361,9 +695,9 @@ export default function ChatStream({
const q: string = query.trim().toLowerCase(); const q: string = query.trim().toLowerCase();
if (q.length === 0) return []; if (q.length === 0) return [];
return messages return messages
.filter((m) => searchHaystack(m).includes(q)) .filter((m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q))
.map((m) => m.key); .map((m) => m.key);
}, [messages, query]); }, [messages, tools, query]);
// live events can shrink the match set under the cursor // live events can shrink the match set under the cursor
useEffect(() => { useEffect(() => {
@@ -426,6 +760,21 @@ export default function ChatStream({
<Bubble msg={m} tools={tools} query={query} showTs={showTs} /> <Bubble msg={m} tools={tools} query={query} showTs={showTs} />
</div> </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 />} {showTyping && <TypingIndicator />}
</div> </div>
</div> </div>
+77
View File
@@ -1692,3 +1692,80 @@ describe("ChatView ⋯ menu and timestamps", () => {
expect(container.querySelectorAll(".ts")).toHaveLength(1); 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
View File
@@ -8,6 +8,7 @@ import type {
SetModelBody, SetModelBody,
} from "./protocol"; } from "./protocol";
import { EventType, Route } from "./protocol"; import { EventType, Route } from "./protocol";
import { useTitle } from "./title";
import { ApiError, errMessage, fetchJson } from "./api"; import { ApiError, errMessage, fetchJson } from "./api";
import { import {
deriveChat, 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. */ /** Test seam: drop the module-level catalog cache. */
export function resetCatalogCache(): void { export function resetCatalogCache(): void {
catalogCache = null; catalogCache = null;
@@ -76,6 +86,8 @@ export default function ChatView({ store, pushToast }: Props) {
const [loadError, setLoadError] = useState<string>(""); const [loadError, setLoadError] = useState<string>("");
const [draft, setDraft] = useState<string>(""); const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false); 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 [tasksOpen, setTasksOpen] = useState<boolean>(false);
const [hasOlder, setHasOlder] = useState<boolean>(false); const [hasOlder, setHasOlder] = useState<boolean>(false);
const [loadingOlder, setLoadingOlder] = useState<boolean>(false); const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
@@ -130,15 +142,11 @@ export default function ChatView({ store, pushToast }: Props) {
(incoming: EventFrame[]): void => { (incoming: EventFrame[]): void => {
setEvents((prev) => { setEvents((prev) => {
const merged = mergeEvents(prev, incoming); const merged = mergeEvents(prev, incoming);
lastSeqRef.current = Math.max( lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged));
lastSeqRef.current,
lastPersistedSeq(merged),
);
return merged; return merged;
}); });
// a finished run means usage changed server-side // a finished run means usage changed server-side
if (incoming.some((e) => e.type === EventType.AgentSettled)) if (incoming.some((e) => e.type === EventType.AgentSettled)) refreshStats();
refreshStats();
}, },
[refreshStats], [refreshStats],
); );
@@ -156,6 +164,7 @@ export default function ChatView({ store, pushToast }: Props) {
// leak into the new one // leak into the new one
setDraft(""); setDraft("");
setSending(false); setSending(false);
setQueued([]);
setSearchOpen(false); setSearchOpen(false);
setMenuOpen(false); setMenuOpen(false);
sentRef.current = []; sentRef.current = [];
@@ -210,6 +219,21 @@ export default function ChatView({ store, pushToast }: Props) {
// session must never look busy (B2) // session must never look busy (B2)
const busy: boolean = chat.busy && session?.online !== false; 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( const minSeq: number = useMemo(
() => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))), () => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))),
[events], [events],
@@ -250,9 +274,7 @@ export default function ChatView({ store, pushToast }: Props) {
const t = e.target; const t = e.target;
if ( if (
t instanceof HTMLElement && t instanceof HTMLElement &&
(t.tagName === "INPUT" || (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
t.tagName === "TEXTAREA" ||
t.isContentEditable)
) )
return; return;
e.preventDefault(); e.preventDefault();
@@ -286,30 +308,74 @@ export default function ChatView({ store, pushToast }: Props) {
}; };
}, [menuOpen]); }, [menuOpen]);
const send = async (): Promise<void> => { const postMessage = async (text: string): Promise<boolean> => {
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();
try { try {
await fetchJson(Route.SessionPrompt(sessionId), { await fetchJson(Route.SessionPrompt(sessionId), {
method: "POST", method: "POST",
body: JSON.stringify({ message: text }), body: JSON.stringify({ message: text }),
}); });
return true;
} catch (err) { } catch (err) {
// the message never reached the session: put it back (S6)
setDraft(text);
if (err instanceof ApiError && err.status === 409) if (err instanceof ApiError && err.status === 409)
pushToast("session offline"); pushToast("session offline");
else pushToast(errMessage(err)); 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 { } finally {
setSending(false); 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> => { const abort = async (): Promise<void> => {
try { try {
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
@@ -331,10 +397,7 @@ export default function ChatView({ store, pushToast }: Props) {
} }
}; };
const pickModel = async ( const pickModel = async (provider: string, modelId: string): Promise<void> => {
provider: string,
modelId: string,
): Promise<void> => {
if (switching) return; if (switching) return;
setSwitching(true); setSwitching(true);
try { try {
@@ -421,8 +484,7 @@ export default function ChatView({ store, pushToast }: Props) {
} }
// empty composer + history: ArrowUp recalls the last sent message // empty composer + history: ArrowUp recalls the last sent message
if (e.key === ARROW_UP_KEY && draft.length === 0) { if (e.key === ARROW_UP_KEY && draft.length === 0) {
const last: string | undefined = const last: string | undefined = sentRef.current[sentRef.current.length - 1];
sentRef.current[sentRef.current.length - 1];
if (last !== undefined) { if (last !== undefined) {
e.preventDefault(); e.preventDefault();
setDraft(last); setDraft(last);
@@ -528,17 +590,15 @@ export default function ChatView({ store, pushToast }: Props) {
</div> </div>
)} )}
</div> </div>
{stats !== null && {stats !== null && (stats.inputTokens > 0 || stats.outputTokens > 0) && (
(stats.inputTokens > 0 || stats.outputTokens > 0) && ( <span
<span className="usage-chip"
className="usage-chip" title={`${stats.inputTokens.toLocaleString()}${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
title={`${stats.inputTokens.toLocaleString()}${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`} >
> {formatTokens(stats.inputTokens)} {formatTokens(stats.outputTokens)} ·{" "}
{formatTokens(stats.inputTokens)} {stats.turns} turns · ${stats.totalCost.toFixed(2)}
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $ </span>
{stats.totalCost.toFixed(2)} )}
</span>
)}
<span <span
className={classNames( className={classNames(
"conn-dot", "conn-dot",
@@ -568,11 +628,7 @@ export default function ChatView({ store, pushToast }: Props) {
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div className="chat-menu" role="menu" aria-label="Session actions">
className="chat-menu"
role="menu"
aria-label="Session actions"
>
<button <button
type="button" type="button"
className="chat-menu-item" className="chat-menu-item"
@@ -637,6 +693,8 @@ export default function ChatView({ store, pushToast }: Props) {
messages={chat.messages} messages={chat.messages}
tools={chat.tools} tools={chat.tools}
busy={busy} busy={busy}
queued={queued}
unqueue={unqueue}
hasOlder={hasOlder} hasOlder={hasOlder}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()} onLoadOlder={() => void loadOlder()}
@@ -656,7 +714,7 @@ export default function ChatView({ store, pushToast }: Props) {
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{busy ? ( {busy && (
<button <button
type="button" type="button"
className="abort-btn" className="abort-btn"
@@ -665,17 +723,16 @@ export default function ChatView({ store, pushToast }: Props) {
> >
stop stop
</button> </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> </div>
</div> </div>
+12
View File
@@ -512,3 +512,15 @@ describe("busy indicator on cards", () => {
expect(pips).toHaveLength(1); 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");
});
});
+7
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import type { SessionListItem, StatsTotals } from "./protocol"; import type { SessionListItem, StatsTotals } from "./protocol";
import { Route } from "./protocol"; import { Route } from "./protocol";
import { fetchJson } from "./api"; import { fetchJson } from "./api";
import { useTitle } from "./title";
import { classNames, formatTokens, relativeTime } from "./store"; import { classNames, formatTokens, relativeTime } from "./store";
interface Props { interface Props {
@@ -143,6 +144,12 @@ export default function SessionsView({
const active = sessions.filter((s) => s.online).sort(byActivity); const active = sessions.filter((s) => s.online).sort(byActivity);
const archived = 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> => { const remove = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
+128 -21
View File
@@ -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 { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo, RepoImage, SessionListItem } from "./protocol"; import type { Repo, RepoImage, SessionListItem } from "./protocol";
import { Route as ApiRoute } from "./protocol"; import { Route as ApiRoute } from "./protocol";
import { fetchJson } from "./api"; import { fetchJson } from "./api";
import { resetCatalogCache } from "./ChatView";
import SpawnView from "./SpawnView"; import SpawnView from "./SpawnView";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
@@ -105,8 +112,7 @@ describe("SpawnView connect flow", () => {
connected = true; connected = true;
return { username: "alice" }; return { username: "alice" };
} }
if (url.endsWith("/api/gitlab/repos")) if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
return [repo("g/one"), repo("g/two")];
return []; return [];
}); });
render(tree(makeStore())); render(tree(makeStore()));
@@ -180,9 +186,7 @@ describe("SpawnView repo picker", () => {
expect(screen.queryByText("g/alpha")).toBeNull(); expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument(); expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" }); fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev"); expect(screen.getByLabelText("Branch")).toHaveValue("dev");
@@ -194,9 +198,7 @@ describe("SpawnView repo picker", () => {
connectedMock(); connectedMock();
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
const item = screen const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement;
.getByText("g/alpha")
.closest(".repo-item") as HTMLElement;
const enter = fireEvent.keyDown(item, { key: "Enter" }); const enter = fireEvent.keyDown(item, { key: "Enter" });
expect(enter).toBe(false); // preventDefault consumed: no page scroll expect(enter).toBe(false); // preventDefault consumed: no page scroll
@@ -234,7 +236,11 @@ describe("SpawnView repo picker", () => {
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled(); 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", repo: "g/proj",
startedAt: 1, startedAt: 1,
online: true, online: true,
busy: false, busy: false,
lastEventAt: 1, lastEventAt: 1,
}, },
] ]
@@ -352,8 +358,7 @@ describe("SpawnView spawn+poll", () => {
return []; return [];
}); });
const failingRefresh = makeStore({ const failingRefresh = makeStore({
refresh: (): Promise<SessionListItem[]> => refresh: (): Promise<SessionListItem[]> => Promise.reject(new Error("boom")),
Promise.reject(new Error("boom")),
}); });
render(tree(failingRefresh)); render(tree(failingRefresh));
await flush(); await flush();
@@ -531,10 +536,7 @@ describe("SpawnView repo images (registry)", () => {
const ok = screen.getByText("custom image"); const ok = screen.getByText("custom image");
expect(ok.className).toContain("ok"); expect(ok.className).toContain("ok");
expect(ok).toHaveAttribute( expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built");
"title",
"custom image lvmh-worker-alpha — built",
);
const warn = screen.getByText("needs build"); const warn = screen.getByText("needs build");
expect(warn.className).toContain("warn"); expect(warn.className).toContain("warn");
@@ -560,8 +562,7 @@ describe("SpawnView repo images (registry)", () => {
const prepareCalls = fetchMock.mock.calls.filter( const prepareCalls = fetchMock.mock.calls.filter(
([u, i]) => ([u, i]) =>
String(u).endsWith("/api/repos/g/alpha/prepare") && String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST",
i?.method === "POST",
); );
expect(prepareCalls).toHaveLength(1); expect(prepareCalls).toHaveLength(1);
expect(PUSH_TOAST).toHaveBeenCalledWith( expect(PUSH_TOAST).toHaveBeenCalledWith(
@@ -613,8 +614,7 @@ describe("SpawnView repo images (registry)", () => {
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" }; return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
if (url.endsWith("/api/repos")) if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500);
return jsonResponse({ error: "boom" }, 500);
return []; return [];
}); });
render(tree(makeStore())); render(tree(makeStore()));
@@ -667,3 +667,110 @@ describe("SpawnView repo images (registry)", () => {
).toBeInTheDocument(); ).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
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { import type {
GitlabStatus, GitlabStatus,
ModelCatalogEntry,
Repo, Repo,
RepoImage, RepoImage,
SpawnJob, SpawnJob,
@@ -10,6 +11,7 @@ import type {
import { Route } from "./protocol"; import { Route } from "./protocol";
import { errMessage, fetchJson } from "./api"; import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store"; import type { SessionsStore } from "./store";
import { fetchCatalog, groupCatalog } from "./ChatView";
const POLL_MS: number = 1500; const POLL_MS: number = 1500;
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish) 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) => ( {SPAWN_STEPS.map((step, i) => (
<span <span
key={step} key={step}
className={ className={idx > i ? "step done" : idx === i ? "step current" : "step"}
idx > i ? "step done" : idx === i ? "step current" : "step"
}
title={step} title={step}
/> />
))} ))}
@@ -59,10 +59,23 @@ export default function SpawnView({ store, pushToast }: Props) {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null); const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>(""); 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 [busy, setBusy] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [spawning, setSpawning] = useState<SpawnResponse | null>(null); 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 timerRef = useRef<number | null>(null);
const tickRef = useRef<number>(0); const tickRef = useRef<number>(0);
@@ -152,15 +165,19 @@ export default function SpawnView({ store, pushToast }: Props) {
r.path.toLowerCase().includes(query.toLowerCase()), r.path.toLowerCase().includes(query.toLowerCase()),
); );
// spawn is only reachable from the Spawn button, which is disabled until a // spawn is reachable with a repo selected, or with the blank-container
// repo is selected. // checkbox and no repo (empty workspace).
const spawn = async (): Promise<void> => { const spawn = async (): Promise<void> => {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const body = { const body = {
repo: selected!.path, repo: selected?.path ?? "",
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(selected !== null && branch.trim().length > 0
? { branch: branch.trim() }
: {}),
...(model.length > 0 ? { model } : {}),
...(empty ? { empty: true } : {}),
}; };
const res = await fetchJson<SpawnResponse>(Route.Spawn, { const res = await fetchJson<SpawnResponse>(Route.Spawn, {
method: "POST", method: "POST",
@@ -190,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const list = await store.refresh(); const list = await store.refresh();
const s = list.find((x) => x.id === sessionId); const s = list.find((x) => x.id === sessionId);
if (s !== undefined && s.online) { if (s !== undefined && s.online) {
if (timerRef.current !== null) if (timerRef.current !== null) window.clearInterval(timerRef.current);
window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
navigate(`/s/${sessionId}`); navigate(`/s/${sessionId}`);
} }
@@ -219,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) {
job.message !== undefined && job.message.length > 0 job.message !== undefined && job.message.length > 0
? ` (${job.message})` ? ` (${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…"; return "waiting for session to come online…";
}; };
@@ -229,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
<p className="empty"> <p className="empty">
{error.length > 0 {error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
? `gitlab status failed: ${error}`
: "checking gitlab…"}
</p> </p>
</div> </div>
); );
@@ -321,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div className="repo-path">{r.path}</div> <div className="repo-path">{r.path}</div>
<div className="repo-meta"> <div className="repo-meta">
default {r.defaultBranch} ·{" "} default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
{r.lastActivityAt.slice(0, 10)}
</div> </div>
</div> </div>
{reg !== undefined && ( {reg !== undefined && (
@@ -347,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) {
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// let the native button handle Enter/Space // let the native button handle Enter/Space
if (e.key === "Enter" || e.key === " ") if (e.key === "Enter" || e.key === " ") e.stopPropagation();
e.stopPropagation();
}} }}
> >
Prepare Prepare
@@ -373,24 +386,58 @@ export default function SpawnView({ store, pushToast }: Props) {
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
disabled={selected === null || busy} disabled={(selected === null && !empty) || busy}
aria-label="Spawn container" aria-label="Spawn container"
onClick={() => void spawn()} onClick={() => void spawn()}
> >
{busy ? "Spawning…" : "Spawn"} {busy ? "Spawning…" : "Spawn"}
</button> </button>
</div> </div>
{selected === null && ( {selected === null && !empty && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
select a repo above select a repo above or tick Empty container for a blank workspace
</p> </p>
)} )}
{selected !== null && ( {selected !== null && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
Will use:{" "} Will use: {registration(selected.path)?.image ?? "base worker image"}
{registration(selected.path)?.image ?? "base worker image"}
</p> </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>} {error.length > 0 && <p className="error-text">{error}</p>}
</div> </div>
</> </>
+185 -12
View File
@@ -25,8 +25,7 @@
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35); --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45); --shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45);
font-family: font-family:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
sans-serif;
} }
* { * {
@@ -687,6 +686,27 @@ a:hover {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
color: var(--text); 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 { .bubble .tool-card .tool-body {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@@ -708,6 +728,9 @@ a:hover {
color: var(--danger); color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.tool-status-run {
color: var(--text-faint);
}
details.thinking { details.thinking {
margin-bottom: 10px; margin-bottom: 10px;
@@ -754,6 +777,48 @@ details.thinking .thinking-body {
overflow-x: auto; overflow-x: auto;
color: var(--text-dim); 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 { .bubble code {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.92em; font-size: 0.92em;
@@ -1367,6 +1432,26 @@ mark.hit {
} }
@media (max-width: 700px) { @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 { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
@@ -1433,16 +1518,104 @@ mark.hit {
/* ---------- busy activity pip ---------- */ /* ---------- busy activity pip ---------- */
.busy-pip { .busy-pip {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
margin-left: 7px; margin-left: 7px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
animation: busy-pulse 1.1s ease-in-out infinite; animation: busy-pulse 1.1s ease-in-out infinite;
vertical-align: middle; vertical-align: middle;
} }
@keyframes busy-pulse { @keyframes busy-pulse {
0%, 100% { transform: scale(0.55); opacity: 0.45; box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5); } 0%,
50% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 5px rgba(108, 140, 255, 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;
} }
+38
View File
@@ -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);
}