feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)

主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。

改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
  补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
  既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
  落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
  单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
  interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
  规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
  件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
  wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
  出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
  翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
  默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
  算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
  两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
  的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
  条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。

为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。

验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
This commit is contained in:
AskaEth committed 2026-10-05 09:37:26 +08:00
1 parent 82d3ca3953
commit a776e9f608
11 files changed
+462 -92

No files matched your search

@@ -11,7 +11,8 @@ import top.yeij.cyrene.core.prefs.InterruptMode
* A2 客户端半边:**发这条消息之前要不要先打断**(契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* 这个判据此前**根本不存在**——设置页那个开关没有任何发送路径读它,于是「打断」与「追加队列」
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,四条一次钉全:
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,六条一次钉全
* (并行那一档的**另一半**——`chat.parallel`——见 [ParallelForSendTest]):
*
* | 档位 | 会话正忙 | 该发什么 |
* |---|---|---|
@@ -19,12 +20,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode
* | 追加队列(默认) | 空闲 | **什么都不发** |
* | 打断 | 忙 | `interrupt{scope:"current"}` |
* | 打断 | 空闲 | **什么都不发** |
* | 并行 | 忙 | **什么都不发**(它与打断互斥;那一档声明的是 `chat.parallel`) |
* | 并行 | 空闲 | **什么都不发** |
*
* 两条最容易写错的边界各占一列:
* 三条最容易写错的边界各占一列:
* - **默认档不许有任何行为变化**(第一行、第二行):线上请求必须与接入本判据之前逐字节相同;
* - **打断但空闲不发帧**(第四行):命中空闲会话在服务端是 no-op,白发一条帧只是多一次往返、
* 还会在 logcat 里制造「这里打断过」的假象——所以「正忙」必须是**服务端说的**
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的。
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的;
* - **并行档整列都不发**(第五行、第六行):并行与打断互斥,先打断就恰好砍掉要并行的那一路。
* 这不是「这一档碰巧没得发」,是 `Parallel.wireScope == null` 的结构性结论。
*/
class InterruptScopeTest {
@@ -52,8 +57,8 @@ class InterruptScopeTest {
}
@Test
fun `the whole matrix of both modes against both busy states is pinned`() {
// 2 档 × 2 忙闲 = 4 格,一次穷举钉死;将来任何人动这个判据,这里先红。
fun `the whole matrix of all three modes against both busy states is pinned`() {
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
val matrix = InterruptMode.entries.flatMap { mode ->
listOf(true, false).map { busy ->
"${mode.name}/busy=$busy" to interruptScopeForSend(mode, busy)
@@ -66,6 +71,8 @@ class InterruptScopeTest {
"AppendQueue/busy=false" to null,
"Interrupt/busy=true" to "current",
"Interrupt/busy=false" to null,
"Parallel/busy=true" to null,
"Parallel/busy=false" to null,
),
matrix,
)
@@ -85,7 +92,7 @@ class InterruptScopeTest {
// 发送路径取偏好读失败时回落 `AppPrefs()` 的缺省值(见 ChatViewModel.currentInterruptMode 的 KDoc)。
// 这里钉住那个缺省值本身是**追加队列** = 不发任何帧 —— 读不到偏好时回落的方向必须是
// 「最少动作」的那一档,绝不能悄悄变成更激进的「打断」。
// (落盘字符串 → 枚举的映射另有 AppSettings.toInterruptMode 与 InterruptModeTest 钉着。)
// (落盘字符串 → 枚举的映射另有顶层纯函数 toInterruptMode 与 InterruptModeTest 钉着。)
val fallback = AppPrefs().interruptMode
assertEquals(InterruptMode.AppendQueue, fallback)
@@ -94,7 +101,7 @@ class InterruptScopeTest {
}
@Test
fun `the two modes only differ while she is busy`() {
fun `the interrupt mode only differs from the default while she is busy`() {
// 空闲那一列两种档位必须**看起来一样**(都不发);差异只允许出现在「忙」这一列,
// 否则默认档就不是零行为变化了。
assertEquals(
@@ -106,4 +113,17 @@ class InterruptScopeTest {
interruptScopeForSend(InterruptMode.Interrupt, busy = true),
)
}
@Test
fun `the parallel mode is indistinguishable from the default on this axis however busy she is`() {
// 并行与打断互斥,所以这一档在**这条**轴上一个字节都不该多出来:忙闲两种情形都必须
// 与默认档完全一致(它的声明在另一条轴上,见 ParallelForSendTest)。
listOf(true, false).forEach { busy ->
assertEquals(
"busy=$busy 时并行档发出了打断帧——那会砍掉它自己要并行的那一路",
interruptScopeForSend(InterruptMode.AppendQueue, busy),
interruptScopeForSend(InterruptMode.Parallel, busy),
)
}
}
}
@@ -0,0 +1,185 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.data.ws.WsOutbound
/**
* 忙时并行的客户端半边:**发这条消息时要不要声明 `parallel:true`**(忙时并行规范 §2.2),
* 也就是 [interruptScopeForSend] 的兄弟 [parallelForSend]([InterruptScopeTest] 钉的是另一条轴)。
*
* 设置页那栏「她忙时发消息」现在是三选一,两条铁律在这里穷举钉死:
*
* | 档位 | 会话正忙时这一条怎么发 | `interrupt` | `chat.parallel` |
* |---|---|---|---|
* | 追加队列(默认) | 排队等(今天的行为) | **不发** | **不带**(线上逐字节不变) |
* | 打断 | 先取消在跑的那一轮,再发 | `scope:"current"` | **不带** |
* | 并行 | **不打断**,本条另起一路 | **绝不发** | `true` |
*
* 顺手把「空闲时三档逐字节完全相同」钉住:规范 §2.2 只写了 `parallel:true` 是「请求把本条作为
* 独立一路处理」,**没写**空闲时要不要照发。空闲时这条本来就是它自己的回合,声明与不声明在服务端
* 是同一件事 ⇒ 本端取与打断**同一个判据**(只在忙时声明),空闲时切档不会改变一个字节。
*/
class ParallelForSendTest {
/** 加入 `parallel` / 加入本档位之前那条 `chat` 帧的逐字形状。 */
private val legacyChat =
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1"}}"""
/** 同上,但带上了 `parallel:true`(只有「并行」档在忙时才是这个形状)。 */
private val parallelChat =
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1","parallel":true}}"""
/** 「打断」档在忙时那条帧。 */
private val interruptCurrent =
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}"""
/**
* 按偏好把**实际会铺到线上的帧序列**铺出来:`interrupt` 只在 scope 非 `null` 时才发,
* `chat` 永远发一条、`parallel` 键按判定带——与 `ChatViewModel.deliver` 接上
* `ChatSocket.sendChat` / `ChatRequest` 之后的行为同形(那边把这两步接在一起,这里把它摊平)。
*/
private fun wireOf(mode: InterruptMode, busy: Boolean): List<String> = buildList {
interruptScopeForSend(mode, busy)?.let {
add(WsOutbound.Interrupt(sessionId = "web", scope = it).toJson())
}
add(
WsOutbound.Chat(
text = "你好",
sessionId = "web",
deviceId = "d",
clientMsgId = "c-1",
parallel = parallelWireValue(parallelForSend(mode, busy)),
).toJson(),
)
}
// —— 判据本身 ——
@Test
fun `the default mode declares nothing even while she is busy`() {
// 最重要的一条:默认档是绝大多数用户手上的状态,它必须零行为变化。
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = true))
assertEquals(AppPrefs().interruptMode, InterruptMode.AppendQueue)
}
@Test
fun `the default mode declares nothing while she is idle either`() {
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = false))
}
@Test
fun `the parallel mode asks for its own leg while she is busy`() {
assertTrue(parallelForSend(InterruptMode.Parallel, busy = true))
}
@Test
fun `the parallel mode asks for nothing while she is idle`() {
// 空闲时这条本来就是它自己的回合:声明了服务端也没第二路可并。
assertFalse(parallelForSend(InterruptMode.Parallel, busy = false))
}
@Test
fun `the interrupt mode never declares a parallel leg`() {
// 「打断」与「并行」互斥:选了打断就是要她**先停**,同时再要一路并行是自相矛盾。
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = true))
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = false))
}
@Test
fun `the whole matrix of all three modes against both busy states is pinned`() {
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
val matrix = InterruptMode.entries.flatMap { mode ->
listOf(true, false).map { busy -> "${mode.name}/busy=$busy" to parallelForSend(mode, busy) }
}
assertEquals(
listOf(
"AppendQueue/busy=true" to false,
"AppendQueue/busy=false" to false,
"Interrupt/busy=true" to false,
"Interrupt/busy=false" to false,
"Parallel/busy=true" to true,
"Parallel/busy=false" to false,
),
matrix,
)
}
@Test
fun `only the busy parallel cell ever declares a leg`() {
val declaring = InterruptMode.entries.filter { mode ->
listOf(true, false).any { busy -> parallelForSend(mode, busy) }
}
assertEquals(listOf(InterruptMode.Parallel), declaring)
}
// —— 两条铁律:并行绝不打断;另外两档绝不并行 ——
@Test
fun `the parallel mode never sends an interrupt frame however busy she is`() {
listOf(true, false).forEach { busy ->
assertNull("并行档发出打断帧就会砍掉它自己要并行的那一路", interruptScopeForSend(InterruptMode.Parallel, busy))
}
}
@Test
fun `no mode ever declares both an interrupt and a parallel leg`() {
InterruptMode.entries.forEach { mode ->
listOf(true, false).forEach { busy ->
assertFalse(
"${mode.name}/busy=$busy 同时要了打断与并行",
interruptScopeForSend(mode, busy) != null && parallelForSend(mode, busy),
)
}
}
}
// —— 铺到线上的形状 ——
@Test
fun `the whole wire shape of all three modes is pinned frame by frame`() {
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = false))
assertEquals(listOf(interruptCurrent, legacyChat), wireOf(InterruptMode.Interrupt, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Interrupt, busy = false))
// 并行:**没有** interrupt 帧,只有一条多带 `parallel:true` 的 chat 帧。
assertEquals(listOf(parallelChat), wireOf(InterruptMode.Parallel, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Parallel, busy = false))
}
@Test
fun `while she is idle all three modes put exactly the same bytes on the wire`() {
// 空闲是绝大多数发送的情形;切档在这里必须**零差异**(三档的唯一差别是「她正忙时」)。
val wires = InterruptMode.entries.map { wireOf(it, busy = false) }
assertEquals(3, wires.size)
assertTrue("空闲时三档的帧不一致:$wires", wires.all { it == listOf(legacyChat) })
}
@Test
fun `the default mode keeps today's bytes on the wire while she is busy`() {
// 「一个字节都不变」的正面陈述:默认档忙时那条 chat 帧就是加入 `parallel` 之前的字面量。
assertEquals(listOf(legacyChat), wireOf(AppPrefs().interruptMode, busy = true))
}
// —— 判定 → 字段取值的那一步 ——
@Test
fun `a false judgement must omit the key rather than send an explicit false`() {
// `false` 与「省略」在服务端同义(都落回排队),但**线上形状不同**:带上 `"parallel":false`
// 就不再是「与加入该字段之前逐字节相同」。只有 true 才带上该键。
assertNull(parallelWireValue(false))
assertEquals(true, parallelWireValue(true))
assertEquals(legacyChat, wireOf(InterruptMode.AppendQueue, busy = true).last())
assertFalse(wireOf(InterruptMode.AppendQueue, busy = true).last().contains("parallel"))
}
}