feat(gateway): 会话title生成+PATCH改名(A4) — 首条用户消息自动命名+可重命名
- history/title.go: RenameSession(改名字段)+ensureTitle(首条用户消息自动命名,折叠空白取前20rune,纯空白回落主会话/次级会话)
- store.go insert() 前调用 ensureTitle(覆盖Web+IM,最小侵入), HistoryStore 新增 RenameSession
- gateway: PATCH /api/v1/sessions/{id} (owner/admin, ≤80rune, 主会话可改名), handleRenameSession
- 新增/扩展测试(title_test, history_handler_test, fake同步)
- Docs/api-client.md v0.21 同步
This commit is contained in:
1 parent
747b9ad414
commit
89c5fe8027
9 files changed
+466
-4
No files matched your search
@@ -13,6 +13,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
||||
@@ -284,6 +285,154 @@ func TestHistoryDeleteSubSession(t *testing.T) {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// TestHistoryRenameSession covers PATCH /api/v1/sessions/{id}: renaming a sub and
|
||||
// the fixed main session, the input bounds, RBAC and an unknown id.
|
||||
func TestHistoryRenameSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, _ := newHistoryEnv(t)
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Create a sub session to rename.
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var created struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
|
||||
t.Fatalf("decode create data: %v", err)
|
||||
}
|
||||
|
||||
// Rename it and assert the response envelope plus the persisted list.
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": " 我的会话 "}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var renamed struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &renamed); err != nil {
|
||||
t.Fatalf("decode rename data: %v", err)
|
||||
}
|
||||
if renamed.ID != created.SessionID || renamed.Title != "我的会话" {
|
||||
t.Fatalf("rename data = %+v, want id %s title 我的会话", renamed, created.SessionID)
|
||||
}
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var sessions []historySessionSummary
|
||||
decodeDataSlice(t, body, &sessions)
|
||||
found := false
|
||||
for _, m := range sessions {
|
||||
if m.ID == created.SessionID {
|
||||
found = true
|
||||
if m.Title != "我的会话" {
|
||||
t.Fatalf("listed sub title = %q, want 我的会话", m.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("renamed sub %s missing from %+v", created.SessionID, sessions)
|
||||
}
|
||||
|
||||
// The fixed main session may be renamed even though it cannot be deleted.
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+history.MainSessionID, map[string]any{"title": "主控台"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rename main status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var mainRenamed struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &mainRenamed); err != nil {
|
||||
t.Fatalf("decode main rename data: %v", err)
|
||||
}
|
||||
if mainRenamed.ID != history.MainSessionID || mainRenamed.Title != "主控台" {
|
||||
t.Fatalf("main rename data = %+v, want id web title 主控台", mainRenamed)
|
||||
}
|
||||
|
||||
// Empty / whitespace-only / over-long titles are 400.
|
||||
for _, bad := range []string{"", " ", strings.Repeat("字", maxSessionTitleRunes+1)} {
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": bad}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("title %q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// An unknown session id is 404.
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("unknown rename status = %d, want 404 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// A title of exactly the 80-rune bound is accepted.
|
||||
exact := strings.Repeat("字", maxSessionTitleRunes)
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": exact}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("80-rune rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// A viewer cannot rename (owner/admin write).
|
||||
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "nope"}, viewer)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer rename status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistUserMessageNamesSession exercises the Web persistence path end to end:
|
||||
// persistUserMessages (the orchestrator hook the HTTP/WS boundary calls) writes the
|
||||
// first user message through the real store, which names the session. A later user
|
||||
// message leaves the title alone.
|
||||
func TestPersistUserMessageNamesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, _, st := newHistoryEnv(t)
|
||||
o := &Orchestrator{history: st, log: testLogger()}
|
||||
ctx := context.Background()
|
||||
|
||||
// A sub session starts nameless; the first user message names it.
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "帮我规划一次旅行")}, "web:test", "chat")
|
||||
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "第二句话")}, "web:test", "chat")
|
||||
|
||||
metas, err := st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions: %v", err)
|
||||
}
|
||||
var gotSub string
|
||||
for _, m := range metas {
|
||||
if m.ID == sub {
|
||||
gotSub = m.Title
|
||||
}
|
||||
}
|
||||
if gotSub != "帮我规划一次旅行" {
|
||||
t.Fatalf("sub title = %q, want 帮我规划一次旅行", gotSub)
|
||||
}
|
||||
|
||||
// The main session is named from its first Web user message too.
|
||||
o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat")
|
||||
metas, err = st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions again: %v", err)
|
||||
}
|
||||
var gotMain string
|
||||
for _, m := range metas {
|
||||
if m.ID == history.MainSessionID {
|
||||
gotMain = m.Title
|
||||
}
|
||||
}
|
||||
if gotMain != "今天 天气如何" {
|
||||
t.Fatalf("main title = %q, want 今天 天气如何", gotMain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryNotWired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
@@ -296,6 +445,7 @@ func TestHistoryNotWired(t *testing.T) {
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/sessions/web/messages?limit=2", http.StatusNotImplemented},
|
||||
{http.MethodPost, "/api/v1/sessions", http.StatusNotImplemented},
|
||||
{http.MethodPatch, "/api/v1/sessions/web", http.StatusNotImplemented},
|
||||
} {
|
||||
resp, body := doReq(t, tc.method, ts.URL+tc.url, nil, tok)
|
||||
if resp.StatusCode != tc.want {
|
||||
|
||||
Reference in new issue
Block a user