fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断

设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。

现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?

1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
   (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
   里制造「这里打断过」的假象)。

「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。

接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。

偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。

打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。

测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。

文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
This commit is contained in:
AskaEth committed 2026-10-04 22:29:57 +08:00
1 parent b8f0833137
commit cfdc094c30
8 files changed
+224 -7

No files matched your search

@@ -0,0 +1,109 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
/**
* A2 客户端半边:**发这条消息之前要不要先打断**(契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* 这个判据此前**根本不存在**——设置页那个开关没有任何发送路径读它,于是「打断」与「追加队列」
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,四条一次钉全:
*
* | 档位 | 会话正忙 | 该发什么 |
* |---|---|---|
* | 追加队列(默认) | 忙 | **什么都不发** |
* | 追加队列(默认) | 空闲 | **什么都不发** |
* | 打断 | 忙 | `interrupt{scope:"current"}` |
* | 打断 | 空闲 | **什么都不发** |
*
* 两条最容易写错的边界各占一列:
* - **默认档不许有任何行为变化**(第一行、第二行):线上请求必须与接入本判据之前逐字节相同;
* - **打断但空闲不发帧**(第四行):命中空闲会话在服务端是 no-op,白发一条帧只是多一次往返、
* 还会在 logcat 里制造「这里打断过」的假象——所以「正忙」必须是**服务端说的**
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的。
*/
class InterruptScopeTest {
@Test
fun `the default mode sends no interrupt frame even while she is busy`() {
// 最重要的一条:默认档是绝大多数用户手上的状态,它必须零行为变化。
assertNull(interruptScopeForSend(InterruptMode.AppendQueue, busy = true))
}
@Test
fun `the default mode sends nothing while she is idle either`() {
assertNull(interruptScopeForSend(InterruptMode.AppendQueue, busy = false))
}
@Test
fun `interrupting while she is busy carries scope current verbatim`() {
// 逐字 "current",不是 "all":`all` 会连待处理队列一起清掉——那正是 A2 要修的那件事。
assertEquals("current", interruptScopeForSend(InterruptMode.Interrupt, busy = true))
}
@Test
fun `an idle session gets no interrupt frame at all`() {
// 命中空闲会话是 no-op:这一帧唯一的效果是多一次往返与一条会骗人的日志。
assertNull(interruptScopeForSend(InterruptMode.Interrupt, busy = false))
}
@Test
fun `the whole matrix of both modes against both busy states is pinned`() {
// 2 档 × 2 忙闲 = 4 格,一次穷举钉死;将来任何人动这个判据,这里先红。
val matrix = InterruptMode.entries.flatMap { mode ->
listOf(true, false).map { busy ->
"${mode.name}/busy=$busy" to interruptScopeForSend(mode, busy)
}
}
assertEquals(
listOf(
"AppendQueue/busy=true" to null,
"AppendQueue/busy=false" to null,
"Interrupt/busy=true" to "current",
"Interrupt/busy=false" to null,
),
matrix,
)
}
@Test
fun `only the busy interrupt cell ever puts a frame on the wire`() {
val sending = InterruptMode.entries.filter { mode ->
listOf(true, false).any { busy -> interruptScopeForSend(mode, busy) != null }
}
assertEquals(listOf(InterruptMode.Interrupt), sending)
}
@Test
fun `the documented fallback for the send path is the default mode and it sends nothing`() {
// 发送路径取偏好读失败时回落 `AppPrefs()` 的缺省值(见 ChatViewModel.currentInterruptMode 的 KDoc)。
// 这里钉住那个缺省值本身是**追加队列** = 不发任何帧 —— 读不到偏好时回落的方向必须是
// 「最少动作」的那一档,绝不能悄悄变成更激进的「打断」。
// (落盘字符串 → 枚举的映射另有 AppSettings.toInterruptMode 与 InterruptModeTest 钉着。)
val fallback = AppPrefs().interruptMode
assertEquals(InterruptMode.AppendQueue, fallback)
assertNull(interruptScopeForSend(fallback, busy = true))
assertNull(interruptScopeForSend(fallback, busy = false))
}
@Test
fun `the two modes only differ while she is busy`() {
// 空闲那一列两种档位必须**看起来一样**(都不发);差异只允许出现在「忙」这一列,
// 否则默认档就不是零行为变化了。
assertEquals(
interruptScopeForSend(InterruptMode.AppendQueue, busy = false),
interruptScopeForSend(InterruptMode.Interrupt, busy = false),
)
assertNotEquals(
interruptScopeForSend(InterruptMode.AppendQueue, busy = true),
interruptScopeForSend(InterruptMode.Interrupt, busy = true),
)
}
}