package main // model_rename_test.go — slice 1: set_model/rename routing, SetSessionName // merge + session_list broadcast, model catalog parsing and PATCH semantics. import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gorilla/websocket" ) func TestAPISetModelRoutingAndOffline409(t *testing.T) { ts, _ := newTestServer(t) if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, `{"provider":"zai-renaud","modelId":"glm-5.3"}`); code != http.StatusConflict { t.Fatalf("offline set_model = %d, want 409", code) } ws := dialAgent(t, ts) _ = ws.WriteJSON(helloFrame("s1")) _ = readFrame(t, ws) if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, `{"modelId":"glm-5.3"}`); code != http.StatusBadRequest { t.Fatalf("set_model without provider = %d, want 400", code) } if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, `{"provider":"zai-renaud"}`); code != http.StatusBadRequest { t.Fatalf("set_model without modelId = %d, want 400", code) } if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, `{"provider":"zai-renaud","modelId":"glm-5.3"}`); code != http.StatusOK || !strings.Contains(body, `"ok":true`) { t.Fatalf("online set_model = %d %s, want 200 ok", code, body) } got := readFrame(t, ws) if got["type"] != evSetModel || got["provider"] != "zai-renaud" || got["modelId"] != "glm-5.3" { t.Fatalf("set_model frame = %v", got) } } func TestStoreSetSessionNameMergesIntoInfoBlob(t *testing.T) { store := openTestStore(t) name := "original" if err := store.UpsertSession(SessionInfo{ ID: "s1", Name: &name, Cwd: "/w", Model: "glm-5.3", Provider: "zai-renaud", StartedAt: 5, }); err != nil { t.Fatalf("upsert: %v", err) } found, err := store.SetSessionName("s1", "renamed") if err != nil || !found { t.Fatalf("SetSessionName = %v %v, want true nil", found, err) } rows, err := store.Sessions() if err != nil || len(rows) != 1 { t.Fatalf("sessions: %v %v", rows, err) } info := rows[0].Info if info.Name == nil || *info.Name != "renamed" { t.Fatalf("name = %v, want renamed", info.Name) } if info.Cwd != "/w" || info.Model != "glm-5.3" || info.Provider != "zai-renaud" || info.StartedAt != 5 { t.Fatalf("merge clobbered sibling fields: %+v", info) } found, err = store.SetSessionName("missing", "x") if err != nil || found { t.Fatalf("SetSessionName unknown id = %v %v, want false nil", found, err) } } // dialWebWS opens the browser websocket and returns a frame channel. func dialWebWS(t *testing.T, ts *httptest.Server) <-chan map[string]any { t.Helper() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + testToken conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial web ws: %v", err) } t.Cleanup(func() { _ = conn.Close() }) frames := make(chan map[string]any, 64) go func() { defer close(frames) for { var m map[string]any if err := conn.ReadJSON(&m); err != nil { return } frames <- m } }() return frames } // waitSessionList reads frames until a session_list names the session `id` // with the wanted name (nil-safe), or fails on timeout. func waitSessionList(t *testing.T, frames <-chan map[string]any, id, wantName string) { t.Helper() deadline := time.After(3 * time.Second) for { select { case m, ok := <-frames: if !ok { t.Fatal("web ws closed while waiting for session_list") } if m["type"] != frameSessionList { continue } for _, s := range m["sessions"].([]any) { row := s.(map[string]any) if row["id"] != id { continue } if name, _ := row["name"].(string); name == wantName { return } } case <-deadline: t.Fatalf("no session_list with %s name %q before timeout", id, wantName) } } } func TestAPIRenamePatchPersistsBroadcastsAndRoutes(t *testing.T) { ts, store := newTestServer(t) frames := dialWebWS(t, ts) // The initial session_list arrives on connect; the agent hello broadcasts // another. Consume both before PATCHing, then expect the renamed list. ws := dialAgent(t, ts) _ = ws.WriteJSON(helloFrame("s1")) _ = readFrame(t, ws) waitSessionList(t, frames, "s1", "") if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, `{"name":"renamed"}`); code != http.StatusOK { t.Fatalf("patch rename = %d, want 200", code) } waitSessionList(t, frames, "s1", "renamed") rows, err := store.Sessions() if err != nil || len(rows) != 1 || rows[0].Info.Name == nil || *rows[0].Info.Name != "renamed" { t.Fatalf("persisted name after patch: %v %v", rows, err) } // live plugin receives the rename frame too got := readFrame(t, ws) if got["type"] != evRename || got["name"] != "renamed" { t.Fatalf("rename frame = %v", got) } if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, `{"name":" "}`); code != http.StatusBadRequest { t.Fatalf("blank rename = %d, want 400", code) } if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/missing", testToken, `{"name":"x"}`); code != http.StatusNotFound { t.Fatalf("unknown session rename = %d, want 404", code) } } func TestParseModelCatalog(t *testing.T) { body := []byte(`{ "providers": { "zai-renaud": {"models": [ {"id": "glm-5.3", "name": "GLM-5.3"}, {"id": "glm-5.4"}, {"name": "idless, skipped"} ]}, "anthropic": {"models": [{"id": "claude-x", "name": "Claude X"}]} } }`) got := parseModelCatalog(body) want := []ModelCatalogItem{ {Provider: "anthropic", ID: "claude-x", Name: "Claude X"}, {Provider: "zai-renaud", ID: "glm-5.3", Name: "GLM-5.3"}, {Provider: "zai-renaud", ID: "glm-5.4", Name: "glm-5.4"}, } if len(got) != len(want) { t.Fatalf("catalog = %+v", got) } for i := range want { if got[i] != want[i] { t.Fatalf("catalog[%d] = %+v, want %+v", i, got[i], want[i]) } } if parseModelCatalog([]byte(`not json`)) != nil { t.Fatal("malformed body must parse to nil") } if out := parseModelCatalog([]byte(`{"providers":{}}`)); len(out) != 0 { t.Fatalf("empty providers = %+v, want empty", out) } } func TestAPIModelCatalog(t *testing.T) { ts, _ := newTestServer(t) dir := t.TempDir() good := filepath.Join(dir, "models.json") if err := os.WriteFile(good, []byte(`{"providers":{"zai-renaud":{"models":[{"id":"glm-5.3","name":"GLM-5.3"}]}}}`), 0o644); err != nil { t.Fatalf("write models.json: %v", err) } t.Setenv(envModelsFile, good) code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "") if code != http.StatusOK { t.Fatalf("catalog = %d %s", code, body) } var entries []ModelCatalogItem if err := json.Unmarshal([]byte(body), &entries); err != nil { t.Fatalf("decode catalog: %v", err) } if len(entries) != 1 || entries[0] != (ModelCatalogItem{Provider: "zai-renaud", ID: "glm-5.3", Name: "GLM-5.3"}) { t.Fatalf("entries = %+v", entries) } bad := filepath.Join(dir, "bad.json") if err := os.WriteFile(bad, []byte(`{`), 0o644); err != nil { t.Fatalf("write bad.json: %v", err) } t.Setenv(envModelsFile, bad) if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("malformed catalog = %d, want 500", code) } t.Setenv(envModelsFile, filepath.Join(dir, "absent.json")) if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, ""); code != http.StatusInternalServerError { t.Fatalf("missing catalog file = %d, want 500", code) } }