From d102f7546958d92bf3016432567d22c1f5d3a369 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 8 Sep 2026 21:17:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(history):=20=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8lastMessage=E9=A2=84=E8=A7=88(B3)=20=E2=80=94=20?= =?UTF-8?q?=E5=8D=95=E6=9F=A5=E8=AF=A2=E6=97=A0N+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - history/session_preview.go: SessionMeta.LastMessage + 单条SQL(LEFT JOIN取每会话ORDER BY created_at DESC,rowid DESC LIMIT 1,走覆盖索引) - ListSessions 迁出重写(签名不变), content折叠空白截前60rune - gateway: historySessionSummary 增 lastMessage{role,content,createdAt}(omitempty), /sessions与/admin/sessions共用 - 新增测试(session_preview_test, history_lastmessage_test) - Docs/api-client.md v0.22 同步 --- Docs/api-client.md | 3 +- internal/runtime/history/session_preview.go | 103 ++++++++++++++ .../runtime/history/session_preview_test.go | 134 ++++++++++++++++++ internal/runtime/history/store.go | 35 +---- internal/services/gateway/history.go | 30 +++- .../gateway/history_lastmessage_test.go | 61 ++++++++ 6 files changed, 331 insertions(+), 35 deletions(-) create mode 100644 internal/runtime/history/session_preview.go create mode 100644 internal/runtime/history/session_preview_test.go create mode 100644 internal/services/gateway/history_lastmessage_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index d3abc07..ae92fa9 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -24,7 +24,7 @@ | 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role}}`(24h,role=owner) | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | -| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt}]}`(主会话 `id="web"` 恒在首位,次级 `web:`);未注入时退化回渠道会话列表 | +| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,lastMessage?}]}`(主会话 `id="web"` 恒在首位,次级 `web:`;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 HistoryStore 回 `501 not_implemented` | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,createdAt}]}`;`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人私聊消息带,主人私聊为空,`omitempty`);非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | @@ -205,6 +205,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.22`(**B3 会话列表 lastMessage 预览**):`GET /api/v1/sessions` 与 `/api/v1/admin/sessions` 每个会话新增可选 **`lastMessage`** `{content,role,createdAt}`(content 折叠空白截前 60 rune + `…`;空会话省略),**单条 SQL**(LEFT JOIN 取每会话 `ORDER BY created_at DESC,rowid DESC LIMIT 1`,无 N+1,走 `idx_chat_msgs_session_created` 覆盖索引)。`unreadCount` 需已读游标设计,后置。 - `v0.21`(**A4 会话 title**):`PATCH /api/v1/sessions/{id}`(**新增**,body `{title}` ≤80 rune → `{data:{id,title}}`;空/超长 400、不存在 404、主会话可改名);会话**首条用户消息到达时自动命名**(`history.append` 前 `ensureTitle`:折叠空白取前 20 rune,纯空白回落 `主会话`/`次级会话`),仅首次命名、不覆盖。`HistoryStore` 新增 `RenameSession`;隔离会话(`sess-xxx`)不在 `chat_sessions` 有行、不自动命名(其本不在 `/sessions` 列表)。**边界**:前端 `createSubSession(title?)` 的 POST body 含 `title` 但后端暂忽略(会被首条消息自动命名覆盖),未处理。 - `v0.20`(**A3 消息 mode 落库**):`POST /api/v1/chat` 与 `ws.chat` 上行新增**可选 `mode`**(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 `Mode` **真实写入**(IM 路径恒 `chat`;Web 路径经 ctx 透传本轮 `mode`,缺省回落 `chat`;恢复/续接路径沿用发起轮次的 `mode`),消息 DTO、`message_persisted`、`channel_persisted` 的 `mode` 不再恒空,供前端按模式渲染气泡(**仅 `chat` 解析 `

` 拆气泡**,其它模式原样整条)。 - `v0.19`(**百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色**):`POST /api/v1/audio/voice-clone`(**新增**,owner-only + 审计,body `{url:"<公网参考音频URL>", prefix?}` → `{data:{voiceId}}`)——走后端 `voice.DashscopeCloner`(百炼 `voice-enrollment` create_voice,`POST {base}/services/audio/tts/customization`,`input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url}`,**只收公网 URL 不收 base64**,返回 `output.voice_id`;**URL 不打日志不写审计**)。`POST /api/v1/audio/speech` 新增**可选 `instruction`**(per-request 情绪/语气指令,如 `"语气开朗俏皮,像女朋友撒娇"`),经 ctx 透传给 TTS adapter;**只对复刻/设计音色生效**(`cosyvoice-v3-flash` 复刻音色可任意指令;系统音色 `longxiaochun_v3` 需固定格式,服务端 `canInstruct` 判定后**永不发送**)。config 新增 `VOICE_TTS_INSTRUCTION`(默认 `""`)、`VOICE_CLONE_PREFIX`(默认 `cyrene`,≤10 字母/数字);`voice.Chain`/`voice.Service` 新增 `CloneVoice`,`VoiceCloner` 接口(cloud rung 提供;未配 cloud 回 `voice_unavailable`)。**⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无)**,且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。 diff --git a/internal/runtime/history/session_preview.go b/internal/runtime/history/session_preview.go new file mode 100644 index 0000000..cd5551f --- /dev/null +++ b/internal/runtime/history/session_preview.go @@ -0,0 +1,103 @@ +package history + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// This file owns the last-message preview carried in the session list +// (GET /api/v1/sessions): a display-only summary of each session's newest message so a +// client can render a conversation row without one request per session. The preview is +// truncated for display and never feeds identity or permission decisions — identity +// comes from channel actor metadata / JWT (project red line). + +// lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the +// session-list response. A longer message is whitespace-collapsed and truncated to this +// many runes with an ellipsis. +const lastMessagePreviewRunes = 60 + +// MessageSummary is a display-only preview of a session's most recent message. +type MessageSummary struct { + Role string // "user" | "assistant" + Content string // whitespace-collapsed, truncated preview + CreatedAt int64 // Unix milliseconds +} + +// listSessionsQuery reads every session plus its newest message in one statement. The +// LEFT JOIN's correlated subquery resolves each session's newest row via the +// (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by +// insertion order), so a session with no messages still appears with NULL preview +// columns. Ordering keeps the main session first, then subs oldest-first. +const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at, + m.role, m.content, m.created_at + FROM chat_sessions s + LEFT JOIN chat_messages m ON m.rowid = ( + SELECT rowid FROM chat_messages + WHERE session_id = s.id + ORDER BY created_at DESC, rowid DESC + LIMIT 1 + ) + ORDER BY (s.type = ?) DESC, s.created_at ASC` + +// ListSessions returns the main session first, then every sub session, oldest first, +// each carrying a LastMessage preview of its newest message (nil when the session has +// none). Sessions and previews are read in a single query, so the query count does not +// grow with the number of sessions. The returned slice is non-nil. +func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) { + rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain) + if err != nil { + return nil, fmt.Errorf("history: list sessions: %w", err) + } + defer func() { _ = rows.Close() }() + + sessions := make([]SessionMeta, 0) + for rows.Next() { + meta, err := scanSessionMeta(rows) + if err != nil { + return nil, fmt.Errorf("history: scan session: %w", err) + } + sessions = append(sessions, meta) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("history: iterate sessions: %w", err) + } + return sessions, nil +} + +// scanSessionMeta reads one joined chat_sessions + last-message row. The three message +// columns are NULL for a session that has never received a message, so they scan into +// nullable holders and only produce a LastMessage when a row is present. +func scanSessionMeta(sc rowScanner) (SessionMeta, error) { + var ( + meta SessionMeta + typeStr string + role sql.NullString + content sql.NullString + lastAt sql.NullInt64 + ) + if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &meta.CreatedAt, &role, &content, &lastAt); err != nil { + return SessionMeta{}, err + } + meta.Type = typeStr + if role.Valid && lastAt.Valid { + meta.LastMessage = &MessageSummary{ + Role: role.String, + Content: previewContent(content.String), + CreatedAt: lastAt.Int64, + } + } + return meta, nil +} + +// previewContent collapses whitespace runs (newlines included) to single spaces and +// truncates to lastMessagePreviewRunes runes with an ellipsis. It is display-only. +func previewContent(content string) string { + joined := strings.Join(strings.Fields(content), " ") + runes := []rune(joined) + if len(runes) <= lastMessagePreviewRunes { + return joined + } + return string(runes[:lastMessagePreviewRunes]) + "…" +} diff --git a/internal/runtime/history/session_preview_test.go b/internal/runtime/history/session_preview_test.go new file mode 100644 index 0000000..c200033 --- /dev/null +++ b/internal/runtime/history/session_preview_test.go @@ -0,0 +1,134 @@ +package history + +import ( + "context" + "strings" + "testing" +) + +// sessionOf returns the SessionMeta of one session from ListSessions, failing the test +// when the session is absent. +func sessionOf(t *testing.T, st *Store, id string) SessionMeta { + t.Helper() + sessions, err := st.ListSessions(context.Background()) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + for _, m := range sessions { + if m.ID == id { + return m + } + } + t.Fatalf("session %q not found in %+v", id, sessions) + return SessionMeta{} +} + +func TestListSessionsLastMessagePreview(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // The empty main session has no preview. + if got := sessionOf(t, st, MainSessionID); got.LastMessage != nil { + t.Fatalf("empty main LastMessage = %+v, want nil", got.LastMessage) + } + + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "first", CreatedAt: 1000}); err != nil { + t.Fatalf("append main user: %v", err) + } + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "latest reply", CreatedAt: 2000}); err != nil { + t.Fatalf("append main assistant: %v", err) + } + if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: "sub question", CreatedAt: 1500}); err != nil { + t.Fatalf("append sub: %v", err) + } + + // Main stays first and carries its newest message, not the first one. + sessions, err := st.ListSessions(ctx) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + if len(sessions) != 2 || sessions[0].ID != MainSessionID { + t.Fatalf("sessions = %+v, want main first", sessions) + } + main := sessions[0].LastMessage + if main == nil || main.Role != RoleAssistant || main.Content != "latest reply" || main.CreatedAt != 2000 { + t.Fatalf("main LastMessage = %+v, want assistant/latest reply/2000", main) + } + subPreview := sessions[1].LastMessage + if subPreview == nil || subPreview.Role != RoleUser || subPreview.Content != "sub question" || subPreview.CreatedAt != 1500 { + t.Fatalf("sub LastMessage = %+v, want user/sub question/1500", subPreview) + } + + // A sub session created after the main one but never written has no preview. + empty, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new empty sub: %v", err) + } + if got := sessionOf(t, st, empty); got.LastMessage != nil { + t.Fatalf("empty sub LastMessage = %+v, want nil", got.LastMessage) + } +} + +func TestListSessionsLastMessageTruncatesAndCollapses(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + long := strings.Repeat("字", lastMessagePreviewRunes+10) + "\n\n尾部" + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: long, CreatedAt: 1000}); err != nil { + t.Fatalf("append long: %v", err) + } + want := strings.Repeat("字", lastMessagePreviewRunes) + "…" + if got := sessionOf(t, st, MainSessionID).LastMessage; got == nil || got.Content != want { + t.Fatalf("truncated preview = %+v, want %q", got, want) + } + + // Whitespace runs (including newlines) collapse to single spaces, and a short + // message is returned whole. + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: " 你好\n\t世界 ", CreatedAt: 1000}); err != nil { + t.Fatalf("append spaced: %v", err) + } + if got := sessionOf(t, st, sub).LastMessage; got == nil || got.Content != "你好 世界" { + t.Fatalf("collapsed preview = %+v, want %q", got, "你好 世界") + } +} + +func TestListSessionsLastMessageTieBreaksByInsertion(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // Two messages sharing a millisecond timestamp: the later insert is the newest. + for _, content := range []string{"older", "newer"} { + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: content, CreatedAt: 5000}); err != nil { + t.Fatalf("append %s: %v", content, err) + } + } + if got := sessionOf(t, st, MainSessionID).LastMessage; got == nil || got.Content != "newer" { + t.Fatalf("tie-break preview = %+v, want newer", got) + } +} + +func TestPreviewContentBounds(t *testing.T) { + t.Parallel() + if got := previewContent(""); got != "" { + t.Fatalf("empty preview = %q, want empty", got) + } + exact := strings.Repeat("a", lastMessagePreviewRunes) + if got := previewContent(exact); got != exact { + t.Fatalf("exact-length preview changed: %q", got) + } + over := strings.Repeat("a", lastMessagePreviewRunes+1) + if got := previewContent(over); got != exact+"…" { + t.Fatalf("over-length preview = %q, want ellipsis", got) + } +} diff --git a/internal/runtime/history/store.go b/internal/runtime/history/store.go index 9f57313..6d894f5 100644 --- a/internal/runtime/history/store.go +++ b/internal/runtime/history/store.go @@ -109,6 +109,11 @@ type SessionMeta struct { Type string // "main" | "sub" Title string CreatedAt int64 + // LastMessage is a display-only preview of the session's newest message, filled + // by ListSessions in the same query. It is nil for a session with no messages. + // A client uses it to render the conversation row without a per-session request; + // identity and permissions never derive from it. + LastMessage *MessageSummary } // HistoryStore abstracts the chat-history store so callers (the gateway) and tests can @@ -481,36 +486,6 @@ func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, erro return sessionID, nil } -// ListSessions returns the main session first, then every sub session, oldest first. The -// returned slice is non-nil. -func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, type, title, created_at FROM chat_sessions - ORDER BY (type = ?) DESC, created_at ASC`, SessionMain) - if err != nil { - return nil, fmt.Errorf("history: list sessions: %w", err) - } - defer func() { _ = rows.Close() }() - - sessions := make([]SessionMeta, 0) - for rows.Next() { - var ( - meta SessionMeta - typeStr string - createdAt int64 - ) - if err := rows.Scan(&meta.ID, &typeStr, &meta.Title, &createdAt); err != nil { - return nil, fmt.Errorf("history: scan session: %w", err) - } - meta.Type = typeStr - meta.CreatedAt = createdAt - sessions = append(sessions, meta) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("history: iterate sessions: %w", err) - } - return sessions, nil -} - // ensureMainSession inserts the fixed main session row when it is absent, so ListSessions // always reports the main session even before any message is written. func (s *Store) ensureMainSession(ctx context.Context) error { diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index bfc408d..5d59b77 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -20,6 +20,18 @@ type historySessionSummary struct { Type string `json:"type"` // "main" | "sub" Title string `json:"title,omitempty"` CreatedAt int64 `json:"createdAt"` + // LastMessage is a display-only preview of the session's newest message, so the + // client can render a conversation row (e.g. the Android session list) without a + // per-session request. It is omitted for a session with no messages. Content is + // already truncated by the store. + LastMessage *historyLastMessage `json:"lastMessage,omitempty"` +} + +// historyLastMessage is the wire shape of the last-message preview in a session summary. +type historyLastMessage struct { + Role string `json:"role"` // "user" | "assistant" + Content string `json:"content"` + CreatedAt int64 `json:"createdAt"` } // historyMessage is the wire shape of one persisted chat message in the @@ -60,15 +72,25 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) { out := make([]historySessionSummary, 0, len(meta)) for _, m := range meta { out = append(out, historySessionSummary{ - ID: m.ID, - Type: m.Type, - Title: m.Title, - CreatedAt: m.CreatedAt, + ID: m.ID, + Type: m.Type, + Title: m.Title, + CreatedAt: m.CreatedAt, + LastMessage: toHistoryLastMessage(m.LastMessage), }) } writeJSON(w, http.StatusOK, out) } +// toHistoryLastMessage maps a stored last-message preview to its wire shape, returning +// nil for a session that has no messages (so the field is omitted from the JSON). +func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage { + if m == nil { + return nil + } + return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt} +} + // handleCreateSubSession creates a new sub session and returns its sessionId. It is a // user-side write, so it is owner/admin-gated like /api/v1/chat. func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) { diff --git a/internal/services/gateway/history_lastmessage_test.go b/internal/services/gateway/history_lastmessage_test.go new file mode 100644 index 0000000..3cde0f6 --- /dev/null +++ b/internal/services/gateway/history_lastmessage_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "context" + "net/http" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// TestHistorySessionsLastMessage asserts the GET /api/v1/sessions wire contract for the +// last-message preview (B3): every session carries lastMessage {role,content,createdAt} +// with the newest message's whitespace-collapsed content, and a message-less session +// omits the field. +func TestHistorySessionsLastMessage(t *testing.T) { + t.Parallel() + _, ts, jwt, st := newHistoryEnv(t) + ctx := context.Background() + if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: 1000}); err != nil { + t.Fatalf("seed user: %v", err) + } + if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: " 在的\n请说 ", CreatedAt: 2000}); err != nil { + t.Fatalf("seed assistant: %v", err) + } + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + // Decode into a JSON-tagged shape so the wire contract (key names) is asserted, + // not just the in-package struct. + var sessions []struct { + ID string `json:"id"` + LastMessage *struct { + Role string `json:"role"` + Content string `json:"content"` + CreatedAt int64 `json:"createdAt"` + } `json:"lastMessage"` + } + decodeDataSlice(t, body, &sessions) + if len(sessions) != 2 || sessions[0].ID != history.MainSessionID { + t.Fatalf("sessions = %+v, want main first", sessions) + } + main := sessions[0].LastMessage + if main == nil || main.Role != history.RoleAssistant || main.Content != "在的 请说" || main.CreatedAt != 2000 { + t.Fatalf("main lastMessage = %+v, want assistant/在的 请说/2000", main) + } + // The empty sub session omits lastMessage entirely. + if sessions[1].ID != sub { + t.Fatalf("sessions[1].id = %q, want %q", sessions[1].ID, sub) + } + if sessions[1].LastMessage != nil { + t.Fatalf("empty sub lastMessage = %+v, want omitted", sessions[1].LastMessage) + } +}