package api import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" ) func sessionsTestDir(t *testing.T, configPath string) string { t.Helper() cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } return dir } func TestHandleListSessions_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "history-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "Explain why the history API is empty after migration.", }); err != nil { t.Fatalf("AddFullMessage(user) error = %v", err) } if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "assistant", Content: "Because the API still reads only legacy JSON session files.", }); err != nil { t.Fatalf("AddFullMessage(assistant) error = %v", err) } if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "tool", Content: "ignored", }); err != nil { t.Fatalf("AddFullMessage(tool) error = %v", err) } if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } if items[0].ID != "history-jsonl" { t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl") } if items[0].MessageCount != 2 { t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } if items[0].Title != "JSONL-backed session" { t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session") } if items[0].Preview != "Explain why the history API is empty after migration." { t.Fatalf("items[0].Preview = %q", items[0].Preview) } } func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "summary-title" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "fallback preview", }); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } if err := store.SetSummary( nil, sessionKey, " This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ", ); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } expectedTitle := truncateRunes( "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.", maxSessionTitleRunes, ) if items[0].Title != expectedTitle { t.Fatalf("items[0].Title = %q", items[0].Title) } if items[0].Preview != "fallback preview" { t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview") } } func TestHandleGetSession_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "detail-jsonl" for _, msg := range []providers.Message{ {Role: "user", Content: "first"}, {Role: "assistant", Content: "second"}, {Role: "tool", Content: "ignored"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } } if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { ID string `json:"id"` Summary string `json:"summary"` Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if resp.ID != "detail-jsonl" { t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl") } if resp.Summary != "detail summary" { t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary") } if len(resp.Messages) != 2 { t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) } if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) } } func TestHandleDeleteSession_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) store, err := memory.NewJSONLStore(dir) if err != nil { t.Fatalf("NewJSONLStore() error = %v", err) } sessionKey := picoSessionPrefix + "delete-jsonl" if err := store.AddFullMessage(nil, sessionKey, providers.Message{ Role: "user", Content: "delete me", }); err != nil { t.Fatalf("AddFullMessage() error = %v", err) } if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil { t.Fatalf("SetSummary() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) } base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) for _, path := range []string{base + ".jsonl", base + ".meta.json"} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("expected %s to be removed, stat err = %v", path, err) } } } func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) manager := session.NewSessionManager(dir) sessionKey := picoSessionPrefix + "legacy-json" manager.AddMessage(sessionKey, "user", "legacy user") manager.AddMessage(sessionKey, "assistant", "legacy assistant") if err := manager.Save(sessionKey); err != nil { t.Fatalf("Save() error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() dir := sessionsTestDir(t, configPath) base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl")) if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil { t.Fatalf("WriteFile(jsonl) error = %v", err) } h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) listRec := httptest.NewRecorder() listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) mux.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) } var items []sessionListItem if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal(list) error = %v", err) } if len(items) != 0 { t.Fatalf("len(items) = %d, want 0", len(items)) } detailRec := httptest.NewRecorder() detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil) mux.ServeHTTP(detailRec, detailReq) if detailRec.Code != http.StatusNotFound { t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String()) } }