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

This commit is contained in:
Raphael Westphal
2026-08-20 11:44:05 +02:00
parent 9d369f410e
commit 1b6bd54a74
3 changed files with 286 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
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")
}