feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来

改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
  硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
  的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
  仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
  · 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
    loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
  · 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
    重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
    「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
  · ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
  · 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
    契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
  ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
  (refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
  主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。

为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。

验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
This commit is contained in:
AskaEth committed 2026-10-04 22:00:55 +08:00
1 parent a84d473e2f
commit 28c314262e
7 files changed
+549 -24

No files matched your search

@@ -0,0 +1,143 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 正向增量(A5)的**数据层**:`after` 有没有真的上线上、以及服务端的**升序**口径有没有被本端
* 如实接住。
*
* 与 `MessageSearchRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
* `com.sun.net.httpserver`,不引新依赖),断言落在**收到的原始 URI** 上:`before` / `q` / `after`
* 三者互斥是**服务端**的硬约束(同传回 `400`),所以「一次请求里只出现其中一个」必须由 URI 证据
* 来钉,不能只看返回值。
*/
class MessageAfterRepositoryTest {
private val backend = FakeMessagesBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = MessageRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
)
@Test
fun `the forward cursor is the only paging parameter on the wire`() = runBlocking {
backend.reply(200, """{"data":[${message("m1", 100)}]}""")
repository().loadAfter("web", after = 100).getOrThrow()
val uri = backend.uris.single()
assertTrue(uri, uri.contains("after=100"))
assertTrue(uri, uri.contains("limit=50"))
// 互斥的那两个一个都不许出现:服务端同传就回 400,而这条路径恰恰不需要它们。
assertFalse(uri, uri.contains("before="))
assertFalse(uri, uri.contains("q="))
}
@Test
fun `ascending server order is preserved and the newest cursor is the max`() = runBlocking {
// `after` 的契约是**升序**(与 `before` 的倒序相反),照抄这个形状喂进来。
backend.reply(
200,
"""{"data":[${message("m1", 100)}, ${message("m2", 200)}, ${message("m3", 300)}]}""",
)
val page = repository().loadAfter("web", after = 100, limit = 50).getOrThrow()
assertEquals(listOf("m1", "m2", "m3"), page.messages.map { it.id })
assertEquals(100L, page.oldestCreatedAt)
// 下一页的游标是**最新**那条(`after` 方向与 `before` 方向相反,规则也相反)。
assertEquals(300L, page.newestCreatedAt)
assertFalse(page.hasMore)
}
@Test
fun `a full page says there may be more ahead`() = runBlocking {
backend.reply(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
val page = repository().loadAfter("web", after = 0, limit = 2).getOrThrow()
assertTrue(page.hasMore)
}
@Test
fun `the page size is clamped to the server ceiling`() = runBlocking {
backend.reply(200, """{"data":[]}""")
repository().loadAfter("web", after = 0, limit = 999).getOrThrow()
assertTrue(backend.uris.single(), backend.uris.single().contains("limit=200"))
}
@Test
fun `a deleted session surfaces as session_not_found with 404`() = runBlocking {
backend.reply(
404,
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
)
val error = repository().loadAfter("web:gone", after = 0).exceptionOrNull() as CyreneException
val server = error.error as CyreneError.Server
assertEquals("session_not_found", server.code)
assertEquals(404, server.httpStatus)
}
private fun message(id: String, createdAt: Long): String =
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
/** 只回一份固定响应、但把每次请求的原始 URI 记下来的假后端。 */
private inner class FakeMessagesBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val uris = CopyOnWriteArrayList<String>()
@Volatile
private var status: Int = 200
@Volatile
private var body: String = """{"data":[]}"""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
private fun handle(exchange: HttpExchange) {
uris += exchange.requestURI.toString()
val bytes = body.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
}
@@ -0,0 +1,163 @@
package top.yeij.cyrene.ui.chat
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.PagedMessages
/**
* 断线恢复的两块纯逻辑:**「会话没了」怎么判**(契约 v0.63 的 `404 session_not_found`)
* 与 **正向增量循环什么时候停**(契约 §1 行 6 的 `after`)。
*
* 两者都是本次新增的判据,所以都从 `ChatViewModel` 里拎出来做成可单测的纯函数:
* 真要跑起那个 VM 得搭一整套 `AppContainer`,而这里要证的只是两句话。
*/
class ChatRecoveryTest {
// —— 一、「这个会话在服务端已经不存在了」——
@Test
fun `the v0 63 code marks the session as gone`() {
val error = CyreneException(
CyreneError.Server(
code = "session_not_found",
message = """session "web:gone" not found""",
httpStatus = 404,
),
)
assertTrue(error.isSessionGone())
}
@Test
fun `a 404 without an error envelope still counts`() {
// 老部署 / 代理兜底时可能没有 `error` 信封(code 是空串):`/messages` 上的 404 只有
// 一个含义,不能因为少一个字段就把它读成「空会话」——那正是主人那次「图凭空消失」。
assertTrue(CyreneException(CyreneError.Server("", "", 404)).isSessionGone())
}
@Test
fun `the code wins over the status`() {
// 码是机器可读的那一份,状态码只是兜底:有码就按码判。
assertTrue(CyreneException(CyreneError.Server("session_not_found", "", 410)).isSessionGone())
}
@Test
fun `other failures are not a deleted session`() {
assertFalse(CyreneException(CyreneError.Server("bad_request", "invalid limit", 400)).isSessionGone())
assertFalse(CyreneException(CyreneError.Server("internal", "boom", 500)).isSessionGone())
assertFalse(CyreneException(CyreneError.Server("insufficient_quota", "", 402)).isSessionGone())
assertFalse(CyreneException(CyreneError.Unauthorized("", "")).isSessionGone())
assertFalse(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).isSessionGone())
assertFalse(RuntimeException("boom").isSessionGone())
}
@Test
fun `any 404 from the messages endpoint counts as gone`() {
// `/messages` 上的 404 只有「会话不存在」这一个来源(消息端点 `/messages/{id}` 走的是
// 另一条路径),所以码对不上也照判:宁可如实说「这个会话没了」,也不要把它渲染成
// 一条空会话——那正是主人那次「图片凭空消失」。
assertTrue(CyreneException(CyreneError.Server("not_found", "会话不存在", 404)).isSessionGone())
}
// —— 二、正向增量循环 ——
@Test
fun `it keeps asking with the newest cursor until a short page`() = runBlocking {
val cursors = mutableListOf<Long>()
val pages = listOf(
page(listOf("m1" to 100L, "m2" to 200L), hasMore = true),
page(listOf("m3" to 300L), hasMore = false),
)
val taken = loadForwardPages(from = 50L) { cursor ->
cursors += cursor
Result.success(pages[cursors.size - 1])
}
// 游标取的是**最新**那条(升序口径),不是最旧的。
assertEquals(listOf(50L, 200L), cursors)
assertEquals(2, taken.size)
}
@Test
fun `an empty page ends the walk`() = runBlocking {
var calls = 0
val taken = loadForwardPages(from = 100L) {
calls++
Result.success(page(emptyList(), hasMore = false))
}
assertEquals(1, calls)
assertTrue(taken.isEmpty())
}
@Test
fun `a cursor that cannot advance stops instead of looping forever`() = runBlocking {
// 同毫秒并列且超过一页:契约 §A5 明写的已知边界——纯 createdAt 游标推不动。
// 不做这道闸,这里会拿同一批消息无限请求下去。
var calls = 0
val taken = loadForwardPages(from = 100L) {
calls++
Result.success(page(listOf("m1" to 100L, "m2" to 100L), hasMore = true))
}
assertEquals(1, calls)
assertEquals(1, taken.size)
}
@Test
fun `the round cap bounds one catch-up`() = runBlocking {
var calls = 0
val taken = loadForwardPages(from = 0L, maxRounds = 3) { cursor ->
calls++
Result.success(page(listOf("m$calls" to cursor + 1), hasMore = true))
}
assertEquals(3, calls)
assertEquals(3, taken.size)
}
@Test
fun `a failure keeps what was already fetched and stops`() = runBlocking {
var calls = 0
val taken = loadForwardPages(from = 0L) {
calls++
if (calls == 1) {
Result.success(page(listOf("m1" to 100L), hasMore = true))
} else {
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
}
}
assertEquals(2, calls)
assertEquals(listOf("m1"), taken.single().messages.map { it.id })
}
private fun page(entries: List<Pair<String, Long>>, hasMore: Boolean): PagedMessages {
val messages = entries.map { (id, createdAt) ->
HistoryMessageDto(
id = id,
sessionId = "web",
role = "user",
content = "",
createdAt = createdAt,
)
}
return PagedMessages(
messages = messages,
hasMore = hasMore,
oldestCreatedAt = messages.minOfOrNull { it.createdAt },
newestCreatedAt = messages.maxOfOrNull { it.createdAt },
)
}
}