feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播

- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
This commit is contained in:
AskaEth committed 2026-10-03 16:47:55 +08:00
1 parent 04a8ee0727
commit e8eccd2bb2
14 files changed
+855 -25

No files matched your search

+12 -3
View File
@@ -26,13 +26,14 @@
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 | | 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | | 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` 才解析 `<p>` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | | 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` 才解析 `<p>` 拆气泡)。**会话编排(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,pinned,lastMessage?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:<uuid>`;`pinned` 恒返回 bool;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` | | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) | | 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) | | 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两者都改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | | 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两者都改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | | 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) | | 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) | | 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/<format>`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令;**静默降级空音频时仍 `500 tts_empty`,并附 `X-Voice-Provider: silent` 响应头**,客户端据此提示「没生成声音」) |
@@ -144,7 +145,7 @@
## 3. WebSocket `/ws/chat`(实时) ## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 - **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。 - **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)。 > **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)。
@@ -218,6 +219,12 @@
- **客户端处理**:与 `message_persisted` **同一过滤规则**——仅当 `sessionId === activeSessionId` 时处理(`message_deleted` 的 `sessionId` 缺失时按 `id` 兜底:消息 `id` 全局唯一,只会命中它自己那条)。`message_edited` → **按 `id` 原位更新该条的 `content`**(不新增行:找不到该 `id` 即忽略,避免把别的会话的消息注入当前列表);`message_deleted` → **按 `id` 从列表移除**。两个操作都**不触发整页重拉**——这正是它相对「重拉绕行」的收益。 - **客户端处理**:与 `message_persisted` **同一过滤规则**——仅当 `sessionId === activeSessionId` 时处理(`message_deleted` 的 `sessionId` 缺失时按 `id` 兜底:消息 `id` 全局唯一,只会命中它自己那条)。`message_edited` → **按 `id` 原位更新该条的 `content`**(不新增行:找不到该 `id` 即忽略,避免把别的会话的消息注入当前列表);`message_deleted` → **按 `id` 从列表移除**。两个操作都**不触发整页重拉**——这正是它相对「重拉绕行」的收益。
- **失败不广播**:`404`(消息不存在)/ `400`(内容非法)/ `501`(未注入 HistoryStore)**不**产生任何事件,客户端不会因一次失败请求而丢掉本地消息。 - **失败不广播**:`404`(消息不存在)/ `400`(内容非法)/ `501`(未注入 HistoryStore)**不**产生任何事件,客户端不会因一次失败请求而丢掉本地消息。
### 3.9 已读游标事件(`session_read`,D4)
- **触发**:`POST /api/v1/sessions/{id}/read` **成功推进游标后**由网关广播(与 `message_persisted` 同一条 hub 广播路径:**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅)。用途是**同账号多端清红点**:一端读了,其它端不用重拉列表就把该会话的未读清零。
- **载荷**:`{type:"session_read", sessionId, data:{readAt}}`——`readAt` 为该会话推进后的**已读游标**(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。
- **客户端处理**:仅当 `sessionId === activeSessionId`(或该会话在自己维护的会话列表里)时处理 → 把该会话 `unreadCount` 置 0(更精确的做法是按 `readAt` 重算本地未读;本地没有更完整的数据时直接清零也不会错,因为 `readAt` 只前进)。**它只驱动渲染**:唯一真源仍是后端 `chat_sessions.read_at`,漏收该帧的端在下一次 `GET /api/v1/sessions` 自然收敛(不需要补发、不需要重连)。
- **失败不广播**:`400`(`before` 非法)/ `404`(会话不存在)/ `501`(未注入 HistoryStore)**不**产生事件,其它端不会误清红点。
## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用) ## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
### 4.1 一次性接入流程(三步走) ### 4.1 一次性接入流程(三步走)
@@ -226,8 +233,9 @@
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。 3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天) ### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。 - **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。 - **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。 - **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。 - **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
- **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。 - **搜索**:`q` 非空 = **会话内**内容搜索(倒序,仍支持 `before`/`limit` 分页);空 `q` = 普通分页;`q` 与 `after` 互斥。**不跨会话**。
@@ -253,6 +261,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录 ## 5. 变更记录
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
- `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。 - `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。
- `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。 - `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。
- `v0.34`(**D3 收尾:消息编辑 / 删除的 WS 实时广播**,对客户端**新增两个下行事件、无 REST 契约变更**):`PATCH /api/v1/messages/{id}` 成功后广播 **`message_edited`**(`{type, sessionId, data:<更新后的完整消息DTO>}`,`thinking` 已剥离)、`DELETE /api/v1/messages/{id}` 成功后广播 **`message_deleted`**(`{type, sessionId, data:{id}}`);两者复用 `message_persisted` 的同一 hub 广播路径(**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅),客户端**按 `id` 原位改 `content` / 按 `id` 移除**,不再需要「改/删后重拉」的绕行。`message_deleted` 的 `sessionId` 由后端在删除前读取消息所属会话填入(`HistoryStore` 新增 `GetMessage`;读不到时客户端按全局唯一的 `id` 兜底)。`404`/`400`/`501` 等失败路径**不广播**。Web 端已消费(`lib/ws.ts` + `store/useAppStore` 新增 `updateMessageById`/`removeMessageById`)。详见 §3.8。 - `v0.34`(**D3 收尾:消息编辑 / 删除的 WS 实时广播**,对客户端**新增两个下行事件、无 REST 契约变更**):`PATCH /api/v1/messages/{id}` 成功后广播 **`message_edited`**(`{type, sessionId, data:<更新后的完整消息DTO>}`,`thinking` 已剥离)、`DELETE /api/v1/messages/{id}` 成功后广播 **`message_deleted`**(`{type, sessionId, data:{id}}`);两者复用 `message_persisted` 的同一 hub 广播路径(**全连接广播 + 客户端按 `sessionId` 过滤**,hub 无按会话订阅),客户端**按 `id` 原位改 `content` / 按 `id` 移除**,不再需要「改/删后重拉」的绕行。`message_deleted` 的 `sessionId` 由后端在删除前读取消息所属会话填入(`HistoryStore` 新增 `GetMessage`;读不到时客户端按全局唯一的 `id` 兜底)。`404`/`400`/`501` 等失败路径**不广播**。Web 端已消费(`lib/ws.ts` + `store/useAppStore` 新增 `updateMessageById`/`removeMessageById`)。详见 §3.8。
+6 -3
View File
@@ -40,9 +40,12 @@ func TestMigrate0009PinnedColumnIdempotent(t *testing.T) {
t.Fatal("chat_sessions.pinned should still exist after idempotent re-run") t.Fatal("chat_sessions.pinned should still exist after idempotent re-run")
} }
// Down one step rolls back only 0009, dropping the column. // Roll back every migration newer than 0008 (0009 itself plus any migration added
if err := MigrateDown(ctx, conn, 1, testLogger()); err != nil { // after it, currently 0010), so this test keeps validating the 0009 down no matter
t.Fatalf("MigrateDown(1): %v", err) // how many later migrations the repo has grown.
steps := len(upMigrationIDs(t)) - 8
if err := MigrateDown(ctx, conn, steps, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", steps, err)
} }
if columnExists(t, ctx, conn, "chat_sessions", "pinned") { if columnExists(t, ctx, conn, "chat_sessions", "pinned") {
t.Fatal("chat_sessions.pinned should be dropped by the 0009 down") t.Fatal("chat_sessions.pinned should be dropped by the 0009 down")
@@ -0,0 +1,9 @@
-- 0010_chat_session_read_at down: drop the read cursor added by the up migration.
-- SQLite DROP COLUMN (3.35+) is already relied on by 0006/0007/0009, and read_at is
-- not referenced by any index, view or constraint, so a plain drop is safe. The
-- rolled-back state is the pre-D4 one: no unread badge data at all (the cursor is
-- derived display state, not data a session needs to stay valid). The backfilled
-- values cannot be restored by a re-up beyond the newest-message rule, which is
-- exactly the upgrade default the up migration applies.
ALTER TABLE chat_sessions DROP COLUMN read_at;
@@ -0,0 +1,34 @@
-- 0010_chat_session_read_at: per-session read cursor backing the session-list
-- unread badge (DevDocs/design/16 §2.2/§4.2, Android 协同批次 D4/B3). There is a
-- single owner account and no per-device identity at this layer, so the cursor is
-- session-scoped: reading on any one end marks the session read for every end of
-- the account (one cursor, no per-user/per-device cursor table).
--
-- read_at is a Unix MILLISECOND timestamp in the same unit as
-- chat_messages.created_at (both are INTEGER ms columns; the store assigns
-- time.Now().UnixMilli()), so the unread count is a plain
-- `created_at > read_at` comparison. 0 means "nothing read yet".
--
-- Backfill on upgrade: every existing session's cursor is set to its newest
-- message timestamp, so an upgrade does not suddenly light up the whole session
-- list as unread (a session with no messages — and a session whose messages do not
-- exist — stays at 0, which is also 0 unread).
--
-- The 0003 chat_sessions table already exists at this point, so ALTER ADD COLUMN is
-- the correct, non-destructive step. The canonical schema (including this column) is
-- also re-issued idempotently by internal/runtime/history.NewStore so a fresh
-- in-memory store used by tests stays self-contained; the migration is the
-- authoritative path for an existing database.
--
-- No new index is needed: the unread count filters
-- `session_id = ? AND created_at > ?`, which the existing
-- idx_chat_msgs_session_created (session_id, created_at DESC) already covers as an
-- equality + range scan.
ALTER TABLE chat_sessions ADD COLUMN read_at INTEGER NOT NULL DEFAULT 0;
UPDATE chat_sessions
SET read_at = COALESCE(
(SELECT MAX(m.created_at) FROM chat_messages m WHERE m.session_id = chat_sessions.id),
0
);
+26 -18
View File
@@ -7,11 +7,12 @@ import (
"strings" "strings"
) )
// This file owns the last-message preview carried in the session list // This file owns the two derived per-session display fields carried in the session
// (GET /api/v1/sessions): a display-only summary of each session's newest message so a // list (GET /api/v1/sessions): the last-message preview and the unread count. Both are
// client can render a conversation row without one request per session. The preview is // read-only projections so a client can render a conversation row (with its unread
// truncated for display and never feeds identity or permission decisions — identity // badge) without one request per session. The preview is truncated for display, and
// comes from channel actor metadata / JWT (project red line). // neither field ever 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 // 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 // session-list response. A longer message is whitespace-collapsed and truncated to this
@@ -25,14 +26,19 @@ type MessageSummary struct {
CreatedAt int64 // Unix milliseconds CreatedAt int64 // Unix milliseconds
} }
// listSessionsQuery reads every session plus its newest message in one statement. The // listSessionsQuery reads every session plus its newest message and its unread count
// LEFT JOIN's correlated subquery resolves each session's newest row via the // in one statement. The LEFT JOIN's correlated subquery resolves each session's newest
// (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by // row via the (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond
// insertion order), so a session with no messages still appears with NULL preview // tie by insertion order), so a session with no messages still appears with NULL preview
// columns. Ordering floats pinned sessions first, then keeps the main session before // columns. The unread count is a correlated COUNT(*) over the same index
// unpinned subs, then subs oldest-first. // (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats
// pinned sessions first, then keeps the main session before unpinned subs, then subs
// oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
m.role, m.content, m.created_at m.role, m.content, m.created_at,
(SELECT COUNT(*) FROM chat_messages c
WHERE c.session_id = s.id AND c.created_at > s.read_at)
FROM chat_sessions s FROM chat_sessions s
LEFT JOIN chat_messages m ON m.rowid = ( LEFT JOIN chat_messages m ON m.rowid = (
SELECT rowid FROM chat_messages SELECT rowid FROM chat_messages
@@ -44,8 +50,9 @@ const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
// ListSessions returns pinned sessions first, then the main session, then every sub // ListSessions returns pinned sessions first, then the main session, then every sub
// session, oldest first, each carrying a LastMessage preview of its newest message (nil // 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 // when the session has none) and the UnreadCount derived from the session's read cursor.
// query count does not grow with the number of sessions. The returned slice is non-nil. // Sessions, previews and unread counts 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) { func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain) rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
if err != nil { if err != nil {
@@ -67,9 +74,10 @@ func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
return sessions, nil return sessions, nil
} }
// scanSessionMeta reads one joined chat_sessions + last-message row. The three message // scanSessionMeta reads one joined chat_sessions + last-message + unread-count row. The
// columns are NULL for a session that has never received a message, so they scan into // three message columns are NULL for a session that has never received a message, so they
// nullable holders and only produce a LastMessage when a row is present. // scan into nullable holders and only produce a LastMessage when a row is present. The
// unread count is a non-NULL COUNT(*), so it scans into a plain int.
func scanSessionMeta(sc rowScanner) (SessionMeta, error) { func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
var ( var (
meta SessionMeta meta SessionMeta
@@ -79,7 +87,7 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
content sql.NullString content sql.NullString
lastAt sql.NullInt64 lastAt sql.NullInt64
) )
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt); err != nil { if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
return SessionMeta{}, err return SessionMeta{}, err
} }
meta.Type = typeStr meta.Type = typeStr
+52
View File
@@ -0,0 +1,52 @@
package history
import (
"context"
"fmt"
"time"
)
// This file owns the per-session read cursor (design/16 §4.2, Android 协同批次
// D4/B3). There is a single owner account and no per-device identity at this
// layer, so the cursor is session-scoped: reading on any one end marks the session
// read for every end of the account. The cursor lives on chat_sessions.read_at in
// the same millisecond unit as chat_messages.created_at, so the unread badge is a
// plain `created_at > read_at` count (see listSessionsQuery).
// MarkSessionRead advances the session's read cursor to before and returns the
// resulting cursor as a Unix millisecond timestamp. A zero before means "now" — the
// caller's "mark everything I have seen" case, which does not have to read the
// clock itself.
//
// The cursor only ever moves forward: the UPDATE applies SQLite's two-argument
// scalar MAX, so a stale or out-of-order request (two ends racing, a retried
// request) can never un-read messages. A negative before therefore leaves the stored
// value untouched rather than moving it backwards.
//
// An unknown session id returns ErrSessionNotFound, matching RenameSession/SetPinned
// — isolated (group/stranger) sessions have no chat_sessions row and are not
// addressable here.
func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) {
ms := before.UnixMilli()
if before.IsZero() {
ms = time.Now().UnixMilli()
}
res, err := s.db.ExecContext(ctx,
`UPDATE chat_sessions SET read_at = MAX(read_at, ?) WHERE id = ?`, ms, sessionID)
if err != nil {
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("history: mark session read %s rows: %w", sessionID, err)
}
if n == 0 {
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, ErrSessionNotFound)
}
var readAt int64
if err := s.db.QueryRowContext(ctx,
`SELECT read_at FROM chat_sessions WHERE id = ?`, sessionID).Scan(&readAt); err != nil {
return 0, fmt.Errorf("history: read session cursor %s: %w", sessionID, err)
}
return readAt, nil
}
@@ -0,0 +1,351 @@
package history
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// These tests cover the D4 per-session read cursor (design/16 §4.2, Android
// collaboration B3): the unread count derived in ListSessions, the forward-only
// cursor semantics of MarkSessionRead, and the 0010 migration's upgrade backfill.
// TestListSessionsUnreadCountFromCursor pins the unread derivation: a message counts as
// unread only when it is strictly newer than the session's cursor (created_at > read_at),
// so the cursor is inclusive of the timestamp it names (a client that has rendered a
// message sent at T marks T read). A fresh session starts at cursor 0 with everything
// unread, and a cursor at the newest message clears the badge.
func TestListSessionsUnreadCountFromCursor(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, ts := range []int64{1000, 2000, 3000} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
}
}
// Never read: all three messages are unread (a brand-new account/session).
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 {
t.Fatalf("initial unreadCount = %d, want 3", got)
}
// Cursor on a message's own timestamp counts that message as read.
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000))
if err != nil {
t.Fatalf("mark read: %v", err)
}
if readAt != 2000 {
t.Fatalf("readAt = %d, want 2000", readAt)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got)
}
// A cursor between two messages reads the earlier one only.
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil {
t.Fatalf("mark read 2500: %v", err)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got)
}
// A cursor at the newest message clears the badge.
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil {
t.Fatalf("mark read 3000: %v", err)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("unreadCount after cursor 3000 = %d, want 0", got)
}
}
// TestListSessionsUnreadCountSessionIsolation pins the per-session scope: each session
// carries its own cursor, so reading the main session must not clear (or inflate) a sub
// session's badge, and vice versa.
func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
for _, m := range []Message{
{SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000},
{SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000},
{SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 2 {
t.Fatalf("main unreadCount = %d, want 2", got)
}
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
t.Fatalf("sub unreadCount = %d, want 1", got)
}
// Reading the main session must leave the sub untouched.
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil {
t.Fatalf("mark main read: %v", err)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("main unreadCount after read = %d, want 0", got)
}
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got)
}
// A message arriving after the read re-opens the main badge.
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil {
t.Fatalf("append main-3: %v", err)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("main unreadCount after new message = %d, want 1", got)
}
}
// TestMarkSessionReadForwardOnly covers the racing/malformed-request side of the cursor
// contract: the cursor never moves backwards, so a stale or repeated request is
// idempotent and can never resurrect already-read messages.
func TestMarkSessionReadForwardOnly(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
for _, ts := range []int64{1000, 2000, 3000} {
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
}
}
// Advance to the newest message.
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000))
if err != nil || readAt != 3000 {
t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err)
}
// A stale request (an end that was behind, or a retry of an older read) must not
// move the cursor back and re-open the badge.
readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000))
if err != nil {
t.Fatalf("stale mark: %v", err)
}
if readAt != 3000 {
t.Fatalf("stale mark readAt = %d, want the cursor to stay at 3000", readAt)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got)
}
// Repeating the same cursor is a no-op, and a negative cursor is clamped by the
// same MAX (it cannot move the stored value at all).
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 {
t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err)
}
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(-5000)); err != nil || readAt != 3000 {
t.Fatalf("negative mark = (%d, %v), want (3000, nil)", readAt, err)
}
}
// TestMarkSessionReadZeroTimeMeansNow covers the "mark everything I have seen" call the
// HTTP layer makes when the body omits before: the zero time resolves to the wall clock,
// which reads every message that already exists.
func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
seedAt := time.Now().UnixMilli()
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: seedAt}); err != nil {
t.Fatalf("seed: %v", err)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
t.Fatalf("unreadCount = %d, want 1", got)
}
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.Time{})
if err != nil {
t.Fatalf("mark zero time: %v", err)
}
if readAt < seedAt {
t.Fatalf("readAt = %d, want >= the seeded message time %d", readAt, seedAt)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("unreadCount after mark-now = %d, want 0", got)
}
}
// TestMarkSessionReadUnknownSession pins the error contract: an id with no chat_sessions
// row (including an isolated group/stranger session) is ErrSessionNotFound, so the HTTP
// layer can answer 404 instead of silently reporting success for a session that does not
// exist.
func TestMarkSessionReadUnknownSession(t *testing.T) {
t.Parallel()
st := newStore(t)
if _, err := st.MarkSessionRead(context.Background(), "web:missing", time.UnixMilli(1000)); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("unknown session err = %v, want ErrSessionNotFound", err)
}
}
// migrationReadAtSQL reads one of the 0010 migration files from the source tree, so the
// test applies the same SQL text the embedded migration runs rather than a copy.
func migrationReadAtSQL(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
if err != nil {
t.Fatalf("read migration %s: %v", name, err)
}
return string(b)
}
// columnPresent reports whether table has a column named column.
func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool {
t.Helper()
var n int
if err := conn.QueryRowContext(context.Background(),
"SELECT count(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&n); err != nil {
t.Fatalf("pragma_table_info(%s): %v", table, err)
}
return n == 1
}
// openLegacyDB opens an in-memory DB carrying the pre-0010 schema (chat_sessions with no
// read_at column) plus two legacy sessions with prior history — the real fixed main
// session "web" and a sub session — so the 0010 migration can be applied to a database
// that really predates this feature.
func openLegacyDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ddls := []string{
`CREATE TABLE chat_sessions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL)`,
`CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
mode TEXT,
mood TEXT,
image TEXT,
thinking TEXT,
platform TEXT NOT NULL DEFAULT 'web',
device_id TEXT NOT NULL DEFAULT '',
sender_name TEXT NOT NULL DEFAULT '',
adapter_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL)`,
`CREATE INDEX idx_chat_msgs_session_created ON chat_messages (session_id, created_at DESC)`,
}
for _, ddl := range ddls {
if _, err := conn.ExecContext(ctx, ddl); err != nil {
t.Fatalf("legacy ddl: %v", err)
}
}
legacy := []struct {
sessionID string
sessionType string
messageAt []int64
}{
{sessionID: MainSessionID, sessionType: SessionMain, messageAt: []int64{1000, 4000}},
{sessionID: "web:legacy", sessionType: SessionSub, messageAt: []int64{1000, 2000}},
}
for _, l := range legacy {
if _, err := conn.ExecContext(ctx,
`INSERT INTO chat_sessions (id, type, title, pinned, created_at) VALUES (?, ?, '', 0, ?)`,
l.sessionID, l.sessionType, int64(100)); err != nil {
t.Fatalf("insert legacy session %s: %v", l.sessionID, err)
}
for i, ts := range l.messageAt {
if _, err := conn.ExecContext(ctx,
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
fmt.Sprintf("%s-%d", l.sessionID, i), l.sessionID, RoleUser, "legacy message", ts); err != nil {
t.Fatalf("insert legacy message: %v", err)
}
}
}
return conn
}
// TestMigration0010ReadAtBackfill exercises the real 0010 migration against a legacy
// database: up adds the column and backfills each session's cursor to its newest message,
// so upgrade day reports 0 unread for existing history — including the fixed main session,
// whose existing history is the whole point of the upgrade — and the store (which
// re-issues the canonical DDL idempotently) then works on top of it; a message that arrives
// after the migration is unread until it is marked read; down drops the column again.
func TestMigration0010ReadAtBackfill(t *testing.T) {
ctx := context.Background()
conn := openLegacyDB(t)
if columnPresent(t, conn, "chat_sessions", "read_at") {
t.Fatal("legacy schema should not have read_at before the migration")
}
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
t.Fatalf("apply 0010 up: %v", err)
}
if !columnPresent(t, conn, "chat_sessions", "read_at") {
t.Fatal("read_at should exist after the 0010 up migration")
}
// The backfill is each session's newest message timestamp, not a blanket 0 (which
// would mark the whole existing history unread) and not the first message's.
for _, tc := range []struct {
sessionID string
want int64
}{
{sessionID: MainSessionID, want: 4000},
{sessionID: "web:legacy", want: 2000},
} {
var backfilled int64
if err := conn.QueryRowContext(ctx,
`SELECT read_at FROM chat_sessions WHERE id = ?`, tc.sessionID).Scan(&backfilled); err != nil {
t.Fatalf("read backfilled cursor %s: %v", tc.sessionID, err)
}
if backfilled != tc.want {
t.Fatalf("backfilled read_at for %s = %d, want its newest legacy message %d", tc.sessionID, backfilled, tc.want)
}
}
// The store comes up on the migrated schema and reports no unread for either
// pre-existing session (upgrade day must not light up every row, main session
// included); the store's INSERT OR IGNORE for the main session must not overwrite
// the backfilled cursor.
st, err := NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new store on migrated db: %v", err)
}
for _, sessionID := range []string{MainSessionID, "web:legacy"} {
if got := sessionOf(t, st, sessionID).UnreadCount; got != 0 {
t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got)
}
}
// A message written after the migration is unread, and marking it read clears it.
if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil {
t.Fatalf("append after migration: %v", err)
}
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 1 {
t.Fatalf("unreadCount after post-migration message = %d, want 1", got)
}
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
t.Fatalf("main unreadCount = %d, want 0 (untouched by the sub session's new message)", got)
}
readAt, err := st.MarkSessionRead(ctx, "web:legacy", time.UnixMilli(9000))
if err != nil || readAt != 9000 {
t.Fatalf("mark read = (%d, %v), want (9000, nil)", readAt, err)
}
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 0 {
t.Fatalf("unreadCount after mark = %d, want 0", got)
}
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
t.Fatalf("apply 0010 down: %v", err)
}
if columnPresent(t, conn, "chat_sessions", "read_at") {
t.Fatal("read_at should be dropped by the 0010 down migration")
}
}
+12 -1
View File
@@ -121,10 +121,20 @@ type SessionMeta struct {
// A client uses it to render the conversation row without a per-session request; // A client uses it to render the conversation row without a per-session request;
// identity and permissions never derive from it. // identity and permissions never derive from it.
LastMessage *MessageSummary LastMessage *MessageSummary
// UnreadCount is the number of messages newer than the session's read cursor
// (design/16 §4.2, D4), computed by ListSessions in the same query. The cursor
// is session-scoped and single-owner: reading on any one end marks the session
// read for every end of the account. Like LastMessage it is display-only —
// identity and permissions never derive from it.
UnreadCount int
} }
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can // HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
// hold an interface rather than a concrete *Store. // hold an interface rather than a concrete *Store. It is deliberately kept to the calls
// the runtime-level consumers (channelbridge, backthink) actually make: the D4 read
// cursor (MarkSessionRead) is declared on the consumer-side interface in
// internal/services/gateway (contracts.go) instead, so a narrower store fake elsewhere
// does not have to implement a cursor it never advances. *Store satisfies both.
type HistoryStore interface { type HistoryStore interface {
// Append persists msg, assigning an id and timestamp when the caller omitted them. // Append persists msg, assigning an id and timestamp when the caller omitted them.
Append(ctx context.Context, msg Message) error Append(ctx context.Context, msg Message) error
@@ -579,6 +589,7 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
type TEXT NOT NULL, type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
read_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)` )`
+5
View File
@@ -116,6 +116,11 @@ type HistoryStore interface {
// history.ErrSessionNotFound. // history.ErrSessionNotFound.
SetPinned(ctx context.Context, sessionID string, pinned bool) error SetPinned(ctx context.Context, sessionID string, pinned bool) error
NewSubSession(ctx context.Context, ownerID string) (string, error) NewSubSession(ctx context.Context, ownerID string) (string, error)
// MarkSessionRead advances a session's read cursor (D4) to before and returns
// the resulting cursor as a Unix millisecond timestamp; a zero before means
// "now". The cursor only moves forward. An unknown id returns
// history.ErrSessionNotFound.
MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error)
ListSessions(ctx context.Context) ([]history.SessionMeta, error) ListSessions(ctx context.Context) ([]history.SessionMeta, error)
} }
+7
View File
@@ -26,6 +26,12 @@ type historySessionSummary struct {
// per-session request. It is omitted for a session with no messages. Content is // per-session request. It is omitted for a session with no messages. Content is
// already truncated by the store. // already truncated by the store.
LastMessage *historyLastMessage `json:"lastMessage,omitempty"` LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
// UnreadCount is the number of messages newer than the session's read cursor
// (D4), derived in the same list query. It is always returned (0 when nothing
// is unread) so a client can render/clear the badge without a second call. The
// cursor is session-scoped and single-owner: reading on any one end clears the
// badge for every end of the account.
UnreadCount int `json:"unreadCount"`
} }
// historyLastMessage is the wire shape of the last-message preview in a session summary. // historyLastMessage is the wire shape of the last-message preview in a session summary.
@@ -79,6 +85,7 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
Pinned: m.Pinned, Pinned: m.Pinned,
LastMessage: toHistoryLastMessage(m.LastMessage), LastMessage: toHistoryLastMessage(m.LastMessage),
UnreadCount: m.UnreadCount,
}) })
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
+4
View File
@@ -83,6 +83,10 @@ func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.H
mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession)))
mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession))) mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession)))
mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession))) mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession)))
// Read cursor (D4 / Android collaboration B3): advancing it clears the session's
// unread badge on every end of the account, so it is an owner/admin history write
// like the other session mutations. It broadcasts a `session_read` frame.
mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead)))
// Single-message edit/delete (Android collaboration batch ③a, D3). A message is // Single-message edit/delete (Android collaboration batch ③a, D3). A message is
// addressed by its own opaque id; both are owner/admin-gated history writes. // addressed by its own opaque id; both are owner/admin-gated history writes.
mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage))) mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage)))
@@ -231,6 +231,13 @@ func (f *fakeHistory) ListSessions(context.Context) ([]history.SessionMeta, erro
return nil, nil return nil, nil
} }
// MarkSessionRead satisfies the HistoryStore contract for the read-cursor path (D4).
// The orchestrator never advances a cursor, so the fake only reports the requested
// cursor back without storing it.
func (f *fakeHistory) MarkSessionRead(_ context.Context, _ string, before time.Time) (int64, error) {
return before.UnixMilli(), nil
}
func (f *fakeHistory) records() []history.Message { func (f *fakeHistory) records() []history.Message {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+97
View File
@@ -0,0 +1,97 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// This file owns the D4 read-cursor surface (Android collaboration B3): the
// POST /api/v1/sessions/{id}/read endpoint that advances a session's read cursor,
// plus the `session_read` realtime frame it broadcasts so the account's other
// browsers clear the unread badge without a re-read. The cursor itself lives in
// chat_sessions.read_at (internal/runtime/history); GET /sessions derives the
// unreadCount from it (history.go). The cursor is session-scoped and single-owner:
// one account, one cursor per session — reading on any one end marks the session
// read for every end.
// wsSessionRead is the WS event type for a read-cursor advance.
const wsSessionRead = "session_read"
// sessionReadRequest is the POST /api/v1/sessions/{id}/read body. Before is a
// pointer so an absent field (an empty body) is distinguishable from an explicit
// value: absent means "now" — the client marks everything it has seen read.
type sessionReadRequest struct {
Before *int64 `json:"before"`
}
// handleMarkSessionRead advances a session's read cursor and answers
// {data:{sessionId, readAt}} with the resulting cursor in Unix milliseconds.
// It is owner/admin-gated like the other history writes (writeAuth).
//
// body {before?} is an optional millisecond timestamp bounding what is marked
// read — messages with created_at <= readAt become read. Absent (or an empty body)
// means now. A present non-positive or non-numeric before answers 400: the cursor
// is a real millisecond timestamp, and "unread by a negative cursor" is not a
// meaningful client request. An unknown session id answers 404; a missing
// HistoryStore answers 501.
//
// On success the new cursor is broadcast as a `session_read` frame so the account's
// other browsers clear the badge too (reading on one end = read everywhere). A
// failure path broadcasts nothing.
func (s *Server) handleMarkSessionRead(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
var req sessionReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
var before time.Time
if req.Before != nil {
if *req.Before <= 0 {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "before must be a positive millisecond timestamp"))
return
}
before = time.UnixMilli(*req.Before)
}
readAt, err := s.history.MarkSessionRead(r.Context(), id, before)
if err != nil {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error("history mark session read", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to mark session read"))
return
}
s.hub.broadcastSessionRead(id, readAt)
writeJSON(w, http.StatusOK, map[string]any{"sessionId": id, "readAt": readAt})
}
// broadcastSessionRead fans a read-cursor advance out to every client as a
// `session_read` frame carrying the session's new cursor, so the same account's
// other ends clear the session's unread badge without a re-read. Like the other
// history frames it is an all-connections broadcast (the hub has no per-session
// subscription), and the client filters it by sessionId; the cursor's single source
// of truth stays chat_sessions.read_at, so a client that missed the frame converges
// on its next GET /sessions.
func (h *hub) broadcastSessionRead(sessionID string, readAt int64) {
h.broadcast(wsEvent{
Type: wsSessionRead,
SessionID: sessionID,
Data: map[string]any{"readAt": readAt},
})
}
@@ -0,0 +1,233 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// These tests cover the D4 read-cursor contract (Android collaboration B3): the
// unreadCount carried by GET /api/v1/sessions, the advance endpoint
// POST /api/v1/sessions/{id}/read, and the `session_read` frame that clears the badge
// on the account's other browsers.
// unreadBySession decodes GET /api/v1/sessions into a map of session id → unreadCount via
// the wire key, so the test asserts the JSON contract rather than the in-package struct.
func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]int {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []struct {
ID string `json:"id"`
UnreadCount int `json:"unreadCount"`
}
decodeDataSlice(t, body, &sessions)
out := make(map[string]int, len(sessions))
for _, s := range sessions {
out[s.ID] = s.UnreadCount
}
return out
}
// TestSessionsUnreadCountAndMarkRead covers the happy path end to end: a session with
// unread messages reports the count, POST /read moves the cursor and answers
// {sessionId, readAt}, the list drops to the messages sent after the cursor, and a
// body-less POST (mark everything seen) clears the badge.
func TestSessionsUnreadCountAndMarkRead(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "b", CreatedAt: 2000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "c", CreatedAt: 3000},
{SessionID: sub, Role: history.RoleUser, Content: "sub", CreatedAt: 1500},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/read"
// Nothing read yet: the main session is fully unread, the sub session independently so.
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 3 || got[sub] != 1 {
t.Fatalf("initial unread = %+v, want main 3 / sub 1", got)
}
// Advance the main cursor to 2000: messages at or before it are read.
resp, body := doReq(t, http.MethodPost, base, map[string]any{"before": 2000}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("mark read status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
env := decodeBody(t, body)
if len(env) != 1 {
t.Fatalf("envelope = %v, want only a data field", env)
}
var data struct {
SessionID string `json:"sessionId"`
ReadAt int64 `json:"readAt"`
}
if err := json.Unmarshal(env["data"], &data); err != nil {
t.Fatalf("decode read data: %v (body=%s)", err, body)
}
if data.SessionID != history.MainSessionID || data.ReadAt != 2000 {
t.Fatalf("read data = %+v, want sessionId %s readAt 2000", data, history.MainSessionID)
}
// Only the later message stays unread, and the sub session is untouched.
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 1 || got[sub] != 1 {
t.Fatalf("unread after cursor 2000 = %+v, want main 1 / sub 1", got)
}
// No body = "mark everything I have seen read" (resolved to now server-side).
resp, body = doReq(t, http.MethodPost, base, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("body-less mark status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
t.Fatalf("decode body-less read data: %v (body=%s)", err, body)
}
if data.ReadAt < 3000 {
t.Fatalf("body-less readAt = %d, want >= the newest message 3000", data.ReadAt)
}
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 || got[sub] != 1 {
t.Fatalf("unread after mark-now = %+v, want main 0 / sub 1", got)
}
markedNow := data.ReadAt
// The cursor only moves forward: re-posting a stale cursor must not re-open the badge.
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("stale mark status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
t.Fatalf("decode stale read data: %v", err)
}
if data.ReadAt != markedNow {
t.Fatalf("stale readAt = %d, want the cursor to stay at %d", data.ReadAt, markedNow)
}
if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 {
t.Fatalf("unread after stale mark = %d, want 0", got[history.MainSessionID])
}
}
// TestMarkSessionReadValidation covers the failure contract: a present but unusable
// `before` answers 400, an unknown session 404, a viewer 403 and a missing HistoryStore
// 501 — none of which may be mistaken for a successful read.
func TestMarkSessionReadValidation(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/web/read"
for _, tc := range []struct {
name string
body any
}{
{name: "zero", body: map[string]any{"before": 0}},
{name: "negative", body: map[string]any{"before": -1}},
{name: "non-numeric", body: map[string]any{"before": "abc"}},
{name: "not an object", body: "not-an-object"},
} {
t.Run(tc.name, func(t *testing.T) {
resp, body := doReq(t, http.MethodPost, base, tc.body, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
// An empty object means "now", not "no-op".
resp, body := doReq(t, http.MethodPost, base, map[string]any{}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty object status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// Unknown session → 404; the session id is not auto-created.
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 1000}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown session status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// A viewer cannot write the cursor (owner/admin history write, like PATCH /sessions).
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
// And no token at all is 401, not a silent success.
resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
}
// TestMarkSessionReadNotWired pins the honest degradation: with no HistoryStore injected
// the endpoint answers 501 not_implemented rather than pretending the cursor moved.
func TestMarkSessionReadNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web/read", map[string]any{"before": 1000}, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// TestMarkSessionReadBroadcasts asserts the cross-end sync frame: a successful advance
// pushes exactly one `session_read` frame scoped to that session and carrying the new
// cursor, while a failed one (404) pushes nothing.
func TestMarkSessionReadBroadcasts(t *testing.T) {
t.Parallel()
srv, ts, jwt, _ := newHistoryEnv(t)
c := newFakeConn(srv.hub, 32)
srv.hub.register(c)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/read", map[string]any{"before": 2000}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
ev := readFakeFrame(t, c)
if ev.Type != "session_read" {
t.Fatalf("frame type = %q, want session_read (frame=%+v)", ev.Type, ev)
}
if ev.SessionID != history.MainSessionID {
t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, history.MainSessionID)
}
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data = %+v, want object", ev.Data)
}
if data["readAt"] != float64(2000) {
t.Fatalf("frame data.readAt = %v, want 2000", data["readAt"])
}
if err := connHasNoFrame(c); err != nil {
t.Fatal(err)
}
// A failed advance must not tell other ends the badge was cleared.
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 2000}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
if err := connHasNoFrame(c); err != nil {
t.Fatal(err)
}
}