From a776e9f6083161a12cd76ff02fd16cef7d70acd7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 09:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E5=A5=B9=E5=BF=99=E6=97=B6=E5=8F=91=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E3=80=8D=E8=BF=BD=E5=8A=A0=E7=AC=AC=E4=B8=89=E6=A1=A3=E3=80=8C?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E3=80=8D=E2=80=94=E2=80=94=E5=BF=99=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E6=89=93=E6=96=AD=E3=80=81=E5=8F=A6=E8=B5=B7=E4=B8=80?= =?UTF-8?q?=E8=B7=AF=EF=BC=88chat.parallel=3Dtrue=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等 (默认档,逐字节不变)、打断先发 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 条)。 --- .../top/yeij/cyrene/core/prefs/AppSettings.kt | 59 ++++-- .../top/yeij/cyrene/data/api/dto/ChatDto.kt | 5 +- .../top/yeij/cyrene/data/ws/ChatSocket.kt | 11 +- .../top/yeij/cyrene/data/ws/WsOutbound.kt | 6 +- .../yeij/cyrene/domain/chat/InterruptScope.kt | 54 ++++- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 111 +++++++---- .../yeij/cyrene/ui/settings/SettingsScreen.kt | 10 +- .../cyrene/core/prefs/InterruptModeTest.kt | 72 +++++-- .../cyrene/data/ws/WsOutboundParallelTest.kt | 7 +- .../cyrene/domain/chat/InterruptScopeTest.kt | 34 +++- .../cyrene/domain/chat/ParallelForSendTest.kt | 185 ++++++++++++++++++ 11 files changed, 462 insertions(+), 92 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelForSendTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt index 929d0d7..2c71881 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/AppSettings.kt @@ -16,27 +16,60 @@ private val Context.appDataStore: DataStore by preferencesDataStore enum class ThemeMode { System, Light, Dark } /** - * 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。 + * 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的 + * `chat.parallel`)。 * - * @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略** - * (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。 + * 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断, + * 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字 + * 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。 + * + * 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、 + * 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。 + * + * @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是 + * 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。 + * @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。 */ -enum class InterruptMode(val wireScope: String?) { +enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) { /** - * 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。 + * 追加队列(默认):忙时只把消息排进队列,不打断也不并行。 * - * 该模式**从不产生 `interrupt` 帧**(判据 `interruptScopeForSend` 的结论恒为 `null`): - * 默认档的线上请求与本偏好存在之前**逐字节相同**。它的 `wireScope` 是 `null`,正是 - * 「整键省略」那一形状——它在这里表达的是**没有要发的东西**,不是「发一条省略 scope 的帧」。 + * 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为 + * `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。 */ - AppendQueue(null), + AppendQueue(wireScope = null, wireParallel = false), /** * 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。 * 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。 */ - Interrupt("current"), + Interrupt(wireScope = "current", wireParallel = false), + + /** + * 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`); + * 服务端并发额度满时**不报错**,这一条落回排队。 + * + * [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是 + * `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧—— + * 一产生就等于先把要并行的那一路砍掉。 + * + * 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能), + * 所以设置页这一栏的 summary 把这句代价明说出来。 + */ + Parallel(wireScope = null, wireParallel = true), +} + +/** + * 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测—— + * `AppSettings` 本体要 Android + DataStore,单测里起不来)。 + * + * 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 + */ +fun String?.toInterruptMode(): InterruptMode = when (this) { + InterruptMode.Interrupt.name -> InterruptMode.Interrupt + InterruptMode.Parallel.name -> InterruptMode.Parallel + else -> InterruptMode.AppendQueue } /** @@ -92,12 +125,6 @@ class AppSettings(context: Context) { else -> ThemeMode.System } - /** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */ - private fun String?.toInterruptMode(): InterruptMode = when (this) { - InterruptMode.Interrupt.name -> InterruptMode.Interrupt - else -> InterruptMode.AppendQueue - } - private companion object { val KEY_THEME_MODE = stringPreferencesKey("theme_mode") val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration") diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt index 0727e88..f56ac16 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt @@ -49,8 +49,9 @@ data class ChatRequest( /** * **请求把本条作为独立一路处理**(忙时并行规范 §2.2,REST 与 WS `chat` **同形**)。 * - * `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**。 - * 本端今天**不发它**:真并行要用户显式声明,而声明入口不在本次范围内(客户端只备好形状)。 + * `null` = **整键省略**(`encodeDefaults = false`),与加入该字段之前**逐字节相同**: + * 用户把设置页那栏「她忙时发消息」选成「**并行**」**且会话正忙**时才给 `true`(判据是纯函数 + * `parallelForSend`;另外两档、以及空闲时都不带这个键)。 * 服务端照准与否都不报错——额度满时落回排队并回 `202 {queued:true}`(伴一条 * `pending_ack{reason:"parallel_limit"}`),已被在飞路 steer 吃进时回 `200 {stream:true}`。 */ diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt index 8237a9d..9b0d93f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.data.ws import top.yeij.cyrene.core.auth.AuthRecovery import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.domain.chat.parallelWireValue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -166,6 +167,9 @@ class ChatSocket( /** * 发送 `chat`(api-reference §6.3)。 * @param clientMsgId 幂等键(契约 v0.56 §3.13);重复到达服务端回 `chat_duplicate`,不落库。 + * @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节 + * 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`, + * 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。 * @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。 */ fun sendChat( @@ -173,12 +177,16 @@ class ChatSocket( text: String, deviceId: String?, clientMsgId: String? = null, + parallel: Boolean = false, ): Boolean = send( WsOutbound.Chat( text = text, sessionId = sessionId, deviceId = deviceId, clientMsgId = clientMsgId, + // 直接传 `false` 会序列化成 `"parallel":false`(`WsOutbound.Chat` 只在 `null` 时省略该键), + // 那不是「未声明」而是**显式否认**——默认两档必须逐字节不变 ⇒ 只有 true 才带上该键。 + parallel = parallelWireValue(parallel), ), ) @@ -191,7 +199,8 @@ class ChatSocket( * * **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前** * 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数 - * `interruptScopeForSend`)。默认档「追加队列」恒不发,所以本方法对线上流量零影响。 + * `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope` + * 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。 * 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。 */ fun sendInterrupt(sessionId: String, scope: String? = null): Boolean = diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt index b746bfc..a23a853 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsOutbound.kt @@ -47,9 +47,9 @@ sealed interface WsOutbound { /** * **请求把本条作为独立一路处理**(忙时并行规范 §2.2,**纯增量**)。 * - * `null` = **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。 - * 本端**今天一个字节都不发它**——真并行需要用户显式声明,而声明入口(UI)不在本次范围内; - * 这里只把线上形状备好,好让「接上它」是一次纯增量而不是一次协议改动。 + * `null`(默认)= **整键省略**:行为与加入该字段之前**逐字相同**(忙时落回排队 + 回合内 steer)。 + * 用户把「她忙时发消息」那栏选成「并行」**且会话正忙**时才给 `true`(判据是纯函数 + * `parallelForSend`;另外两档与空闲时都不带这个键——线上取值由 `parallelWireValue` 翻)。 * * 服务端**照准也不报错**(额度满 ⇒ 落回排队并回 `pending_ack{reason:"parallel_limit"}`; * 已被在飞路 steer 吃进 ⇒ 回 `steer_ack`),所以这个字段永远不该引来一个错误帧。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt index ffd7315..13702a7 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/InterruptScope.kt @@ -2,6 +2,15 @@ package top.yeij.cyrene.domain.chat import top.yeij.cyrene.core.prefs.InterruptMode +/** + * 「她忙时发消息」这一档偏好在上行的**两个声明**(纯函数,VM 与测试共用): + * 协同项 A2 的 `interrupt.scope`([interruptScopeForSend])与忙时并行规范 §2.2 的 + * `chat.parallel`([parallelForSend] / [parallelWireValue])。 + * + * 文件名沿用了先有的那个(`InterruptScope`):并行那一半接上来时,两者是**同一个判据的两个方向** + * (打断 vs 不打断),放在同一处比拆成两个文件更容易看出它们互斥。 + */ + /** * 发这条消息**之前**要不要先发一条 `interrupt`:返回要发的作用域,`null` = 一个字节都不发 * (纯函数,VM 与测试共用)。 @@ -9,13 +18,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode * ## 四条判据(协同 A2 的客户端半边,契约 v0.56 §3.1) * * 1. [InterruptMode.AppendQueue](**默认档**)⇒ 恒为 `null`,线上请求与接入本函数之前**逐字节相同**。 - * 这是最重要的一条:默认档不许有任何行为变化。[InterruptMode.AppendQueue] 的 `wireScope` - * 本来就是 `null`(见那里的 KDoc),所以这一档连「线上取值是什么」都不必判——它天生发不出东西。 + * 这是最重要的一条:默认档不许有任何行为变化。 * 2. [InterruptMode.Interrupt](`wireScope = "current"`)**且会话正忙** ⇒ `"current"`: * 只取消在跑回合、**保留待处理队列**(队列里的消息按编排器正常续跑,一条都不丢)。 * 3. [InterruptMode.Interrupt] 但会话**空闲** ⇒ `null`:命中空闲会话在服务端是 no-op,白发一条帧 * 只是多一次往返,还会在 logcat 里制造「这里打断过」的假象。 - * 4. 判据里**没有**「队列有多深」这一项,见下。 + * 4. [InterruptMode.Parallel] ⇒ **恒为 `null`**(它的 `wireScope` 就是 `null`):并行与打断互斥, + * 并行档先打断就恰好砍掉它要并行的那一路。这一条不是「这一档没得发」,而是**结构性保证**—— + * 谓词只读 `wireScope`,并行档不可能发出打断帧。 + * + * 判据里**没有**「队列有多深」这一项,见下。 * * ## 「正忙」由谁判 * 只认**服务端给的** `session_busy{mode}`(调用方传 `ChatUiState.busyMode != null`)。 @@ -26,3 +38,39 @@ import top.yeij.cyrene.core.prefs.InterruptMode */ fun interruptScopeForSend(mode: InterruptMode, busy: Boolean): String? = if (busy) mode.wireScope else null + +/** + * 发这条消息时要不要声明 `parallel:true`(忙时并行规范 §2.2,[interruptScopeForSend] 的兄弟)。 + * + * ## 三条判据 + * + * 1. [InterruptMode.Parallel] **且会话正忙** ⇒ `true`:不打断在跑的那一路,让本条**另起一路** + * 同时处理(服务端额度满时不报错,这一条落回排队)。 + * 2. 另外两档 ⇒ **恒为 `false`**:`parallel` 是纯增量键,省略它线上请求与接入本判据之前**逐字节相同** + * ([InterruptMode.AppendQueue] 与 [InterruptMode.Interrupt] 的 `wireParallel` 都是 `false`)。 + * 3. 并行的同时**绝不打断**:两件事是一对互斥语义(打断 = 先砍掉她正在说的这一句,并行 = 不打断、 + * 另起一路),所以 [InterruptMode.Parallel] 的 `wireScope` 是 `null` ⇒ 它在 + * [interruptScopeForSend] 里恒为「不发」。反过来,另外两档的 `wireParallel` 是 `false` ⇒ + * 它们永远不带 `parallel` 键。「绝不既打断又并行」不是约定,是这两处属性取值本身 + * (`ParallelForSendTest` 把它钉成穷举断言)。 + * + * ## 为什么空闲时不声明 + * 规范 §2.2 只说 `parallel:true` 是「**请求**把本条作为独立一路处理」,**没写**空闲时要不要照发。 + * 空闲时这一条**本来就是它自己的回合**,声明并行与不声明在服务端是同一件事;而多带一个键就多一处 + * 线上形状差异。于是本端取**与 [interruptScopeForSend] 同一个判据**(忙才声明)——最小惊讶: + * 空闲时三档**逐字节**完全相同,切档不会改变一条普通发送的任何字节。 + * + * @param busy 这条消息要发出去的那一刻,该会话是否有回合在跑(与 [interruptScopeForSend] 同一个来源)。 + */ +fun parallelForSend(mode: InterruptMode, busy: Boolean): Boolean = busy && mode.wireParallel + +/** + * 把 [parallelForSend] 的结论翻成帧 / DTO 那个可选字段的取值:`false` ⇒ `null`(**整键省略**)。 + * + * 为什么必须翻一道:`WsOutbound.Chat.parallel` 与 `ChatRequest.parallel` 的默认值都是 `null`, + * 而「省略」与「显式 `false`」在服务端**不是一回事**(前者 = 未声明,后者 = 明确不要并行); + * kotlinx 的 `encodeDefaults = false` 只省略「等于默认值」的字段 ⇒ 把 `false` 直接传下去,会在 + * **每一条**请求上多出一个 `"parallel":false` 键,默认档就不再逐字节不变了。两个发送层共用这一个 + * 转换点,免得哪天有人在其中一处「顺手」把布尔原样传下去。 + */ +fun parallelWireValue(declare: Boolean): Boolean? = if (declare) true else null diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 7319094..221b95e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -59,6 +59,8 @@ import top.yeij.cyrene.domain.chat.closingBucketOf import top.yeij.cyrene.domain.chat.closingTurn import top.yeij.cyrene.domain.chat.editableMessageOf import top.yeij.cyrene.domain.chat.interruptScopeForSend +import top.yeij.cyrene.domain.chat.parallelForSend +import top.yeij.cyrene.domain.chat.parallelWireValue import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.toDocumentFailure import top.yeij.cyrene.domain.chat.turnBucketOf @@ -962,17 +964,25 @@ class ChatViewModel( * 三处刻意的取舍: * - **不动草稿**:分享不是用户按的发送键,不能把他正在输入的内容顶掉; * - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟; - * - **一次都不打断**:见下面「分享不发打断」一段。 + * - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。 * - * ## 分享不发打断(`allowInterrupt` 恒为 false) + * ## 分享不声明(`applySendPreference` 恒为 false) * 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,上面那个 `forEach` 会逐条投递, - * 而「她正忙时按发送」那个偏好(协同 A2)默认会在**每条**投递前发一条 `interrupt{scope:"current"}`。 - * 照原样接上去,第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上只剩最后一张图的回答, - * 前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。 + * 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。 + * 两种声明在这里**都**是错的: * - * 只有一张图时同样不发:这条规则要**易解释**——「分享从不打断」是一句话, - * 「分享超过一张时才不打断」得先跟人解释清为什么是一张的分界。 - * 用户真的想打断,发送键那条路([send])的口径一字未动。 + * - **打断**(`interrupt{scope:"current"}`):第 2 条起就是在**打断自己刚起的上一轮**——最后屏幕上 + * 只剩最后一张图的回答,前面几条被取消(契约里被取消的回合不落库)。自己打断自己一定是错的。 + * - **并行**(`chat.parallel=true`):它没有破坏性,但同一次分享里的 N 条会**互相索要新的一路**, + * 而服务端单会话额度只有 2 路(规范 §0 的 `CHAT_MAX_PARALLEL_TURNS` 默认档)⇒ 前两条各占一路、 + * 其余落回排队——**同一次分享被劈成两种待遇**,用户也没按过发送键说「这条我要并行」。 + * 批量的语义是「把这些都交给她」,不是「每一条都要抢一路」;而且每条各算一遍就是 N 份算力。 + * + * 只有一张图时同样不声明:这条规则要**易解释**——「分享从不声明」是一句话, + * 「分享超过一张时才不声明」得先跟人解释清为什么是一张的分界。 + * 用户真的想打断 / 想并行,发送键那条路([send])的口径一字未动。 + * (并行那一路的**降级**后果轻得多:额度满时只是落回排队,不会像打断那样毁掉已排队的消息—— + * 所以这条取舍是「更可解释 + 不多花算力」,不是「不做就会坏」。) * * ## 超长正文走文件(主人 2026-10-05 拍板) * 分享进来的正文超过 [DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 时,**落成一份 `.txt`** @@ -1012,9 +1022,9 @@ class ChatViewModel( showShareNotice(SHARE_TEXT_NOT_STAGED) } } - emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, allowInterrupt = false) + emitAndDeliver(text, share.images.firstOrNull(), files, clearDraft = false, applySendPreference = false) share.images.drop(1).forEach { - emitAndDeliver("", it, emptyList(), clearDraft = false, allowInterrupt = false) + emitAndDeliver("", it, emptyList(), clearDraft = false, applySendPreference = false) } } } @@ -1042,16 +1052,16 @@ class ChatViewModel( * * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 - * @param allowInterrupt 这条投递要不要按「她正忙时按发送」那个偏好先发打断帧。**默认 true - * = 用户按发送键的既有口径**;只有 [sendShared] 传 false——它是批量动作,批量里每条都打断 - * 等于自己杀自己(理由见 [sendShared])。 + * @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明: + * `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;只有 [sendShared] + * 传 false——它是批量动作,批量里逐条声明只会自相干扰(两种声明各自的理由见 [sendShared])。 */ private suspend fun emitAndDeliver( text: String, imageUri: String?, documents: List, clearDraft: Boolean, - allowInterrupt: Boolean = true, + applySendPreference: Boolean = true, ) { if (text.isEmpty() && imageUri == null && documents.isEmpty()) return @@ -1079,7 +1089,7 @@ class ChatViewModel( error = null, ) } - deliver(localId, text, imageUri, documents, allowInterrupt) + deliver(localId, text, imageUri, documents, applySendPreference) } private suspend fun deliver( @@ -1087,7 +1097,7 @@ class ChatViewModel( text: String, imageUri: String?, documents: List, - allowInterrupt: Boolean, + applySendPreference: Boolean, ) { // 判定矩阵([DocumentDelivery]):附件 >5 **直接拒绝,绝不裁剪**。这里用只需份数的 // [DocumentDelivery.planForCount]——字节还没读(最多 5×20 MiB 一次性读进内存不可接受), @@ -1120,27 +1130,33 @@ class ChatViewModel( } } - // 偏好「她正忙时按发送的行为」(协同 A2):**这条消息真的要走上去之前**,先把在跑回合打断。 - // 放在这里而不是 [emitAndDeliver] 开头,是刻意的:上面每一段本端拒绝(附件超限 / 图片处理 - // 失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」——先把她正在说的那一轮 - // 打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。 + // 偏好「她忙时发消息」(协同 A2 + 忙时并行规范 §2.2):**这条消息真的要走上去之前**, + // 按档位决定要不要额外声明什么。放在这里而不是 [emitAndDeliver] 开头,是刻意的:上面每一段 + // 本端拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 附件引用不合法)都会「回滚、什么都不发」 + // ——先把她正在说的那一轮打断掉、再什么都不发,是凭空的破坏,而且用户看不到任何理由。 + // + // 偏好**只在这里读一次**,两个声明(打断 / 并行)必然出自同一个档位:读两次的话,用户在两读 + // 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。 // 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。 // - // 只有 [allowInterrupt] 为 true 才发(= 用户按发送键那条路,默认档)。[ChatViewModel.sendShared] - // 传 false:它是**批量**投递,每条都打断就会让第 2 条起把前一条刚起的回合取消掉(理由与 - // 取舍见 [sendShared] 的「分享不发打断」一段)。 - if (allowInterrupt) interruptRunningTurn() + // 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。 + // [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍 + // 见 [sendShared] 的「分享不声明」一段)。 + val mode = currentInterruptMode() + val busy = _state.value.busyMode != null + if (applySendPreference) interruptRunningTurn(mode, busy) + val parallel = applySendPreference && parallelForSend(mode, busy) when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) { SendChannel.Ws -> { - val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId) ?: false + val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false if (!sent) rollback(localId, "发送失败,请重试") } SendChannel.Rest -> if (image == null) { - sendViaRest(localId, text, image = null, documents = payload) + sendViaRest(localId, text, image = null, documents = payload, parallel = parallel) } else { - deliverImage(localId, text, image, payload) + deliverImage(localId, text, image, payload, parallel) } } } @@ -1148,11 +1164,14 @@ class ChatViewModel( /** * 发这条消息之前,按偏好决定要不要先打断在跑回合(协同 A2 / 契约 v0.56 §3.1)。 * - * 判据本身是纯函数 [interruptScopeForSend](默认档恒不发、忙且选了「打断」才发 `scope:"current"`, - * 命中空闲会话不发),这里只做两件事:**取当前偏好**、把帧发出去。它由 [deliver] 在**通道分支 - * 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `allowInterrupt` 为 true 时** + * 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发 + * `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支 + * 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时** * 才调(分享那条批量路径传 false,见 [sendShared])。 * + * [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**: + * 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。 + * * ## 一条已知边界:调用顺序 ≠ 到达顺序(不在本端可控范围) * 本端能保证的只有**调用顺序**(打断帧在这条消息之前发出去)。到达与服务端处理顺序取决于通道: * WS 那条路两帧同连接,REST 那条路是「WS 发打断 + HTTP 发消息」两条道。极端时序下打断可能落在 @@ -1171,27 +1190,25 @@ class ChatViewModel( * * 于是这一条的结果是:**打断没生效 = 行为退回默认档(追加队列)**——那是用户能看懂的状态 * (消息排进队列、她先说完当前这一轮),不是一件被藏起来的事。 - * - * 偏好读不出来(DataStore IO 出错)同样**回落到默认档**(`AppPrefs` 的缺省值 = [InterruptMode.AppendQueue] - * = 不发任何帧):偏好读不到不该让一次发送失败,而回落方向必须是**最少动作**的那一档—— - * 与 AppSettings 把未知取值解成默认档是同一条纪律(「绝不悄悄换成更激进的档位」)。 + * (并行的**失败方向**不同也更轻:它没有「发不出去」这一支——那个键就在 `chat` 帧 / 请求体里, + * 跟着消息一起走;WS 没连着时这条消息整个发不出去,走的是既有的回滚与提示。) */ - private suspend fun interruptRunningTurn() { - val scope = interruptScopeForSend( - mode = currentInterruptMode(), - busy = _state.value.busyMode != null, - ) ?: return + private fun interruptRunningTurn(mode: InterruptMode, busy: Boolean) { + val scope = interruptScopeForSend(mode, busy) ?: return socket?.sendInterrupt(sessionId, scope) } /** - * 取当前的「她正忙时按发送的行为」档位(每次发送现读,不缓存、不订阅)。 + * 取当前的「她忙时发消息」档位(每次发送现读,不缓存、不订阅;[interruptRunningTurn] 与 + * [parallelForSend] 共用这一次读的结果)。 * * 现读的理由:偏好是**用户在设置页里当场切**的东西,而上一个会话里读进字段的值要等 Flow 推到才更新 * (冷启动那一下必然没到)——「切成打断后立刻发的那一条」正是这次要修的场景,缓存住就等于留一个 * 静默不生效的窗口。DataStore 的 `data` 首次收集后走内存缓存,这里不会每发一条就碰一次磁盘。 * - * 读失败(IO / 损坏)回落 `AppPrefs` 的缺省值:理由见 [interruptRunningTurn] 的「偏好读不出来」一段。 + * 读失败(IO / 损坏)回落 `AppPrefs` 的缺省值 = [InterruptMode.AppendQueue](两个声明都不发): + * 偏好读不到不该让一次发送失败,而回落方向必须是**最少动作**的那一档——与 AppSettings 把未知 + * 取值解成默认档是同一条纪律(「绝不悄悄换成更激进的档位」)。 * `CancellationException` 原样抛出(它是取消,不是「读失败」,不能吞成默认档)。 */ private suspend fun currentInterruptMode(): InterruptMode = try { @@ -1306,13 +1323,14 @@ class ChatViewModel( text: String, image: PreparedImage, documents: List, + parallel: Boolean, ) { when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) { - ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents) + ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri, documents, parallel) ImageDelivery.Upload -> container.uploadRepository .uploadImage(image.bytes, image.mime) - .onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents) } + .onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url, documents, parallel) } .onFailure { e -> rollback(localId, uploadFailureMessage(e)) } } } @@ -1320,12 +1338,16 @@ class ChatViewModel( /** * 走 REST 发出这一轮(`stream:true`)。[documents] 是**已上传**的附件引用(空 = 没附件): * 空列表在线上整键省略(`encodeDefaults = false`),所以不带附件的请求与加入该字段之前逐字节相同。 + * + * [parallel] 与 [documents] 同一条纪律:`false` **整键省略**(只有用户选了「并行」且会话正忙时 + * 才是 `true`,判据见 [parallelForSend])——默认档的请求体因此逐字节不变。 */ private suspend fun sendViaRest( localId: String, text: String, image: String?, documents: List = emptyList(), + parallel: Boolean = false, ) { val request = ChatRequest( sessionId = sessionId, @@ -1337,6 +1359,9 @@ class ChatViewModel( stream = true, clientMsgId = localId, documents = documents, + // 直接传 `false` 会序列化成 `"parallel":false`(字段默认值是 `null`,而 + // `encodeDefaults = false` 只省略「等于默认值」的字段)——那不是「未声明」而是**显式否认**。 + parallel = parallelWireValue(parallel), ) container.chatRepository .send(request) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt index d755c78..39186fa 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt @@ -190,15 +190,21 @@ private fun ThemeMode.label(): String = when (this) { private fun InterruptMode.label(): String = when (this) { InterruptMode.AppendQueue -> "追加队列" InterruptMode.Interrupt -> "打断" + InterruptMode.Parallel -> "并行" } /** - * 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点—— - * 一个用户看不懂效果的偏好,比没有这个偏好更糟。 + * 三档的差别只有一处:**忙时这条新消息与在跑的那一句是什么关系**(排队等着 / 把她打断 / 另起一路)。 + * 文案必须说清这一点——一个用户看不懂效果的偏好,比没有这个偏好更糟。 + * + * 「并行」那一条还多说半句**代价**(两路各算一遍 ⇒ 多花一份算力与 token):那是主人 2026-10-05 + * 裁决里接受了的成本(不分摊、优先性能),不是缺陷,但**用户有权知道**他花的这份钱买到了什么。 */ private fun InterruptMode.summary(): String = when (this) { InterruptMode.AppendQueue -> "她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)" InterruptMode.Interrupt -> "她正忙时先打断当前这一句;已排队的消息保留,继续依次回答" + InterruptMode.Parallel -> + "她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token" } diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/prefs/InterruptModeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/InterruptModeTest.kt index 1c9af4e..9237ed4 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/core/prefs/InterruptModeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/InterruptModeTest.kt @@ -1,16 +1,24 @@ package top.yeij.cyrene.core.prefs 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.data.ws.WsOutbound /** - * A2 偏好 → 线上取值 的映射(契约 v0.56 §3.1)。 + * 「她忙时发消息」档位 → 线上取值 的映射(契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 + * §2.2 的 `chat.parallel`)。 * - * 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成 - * **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。 + * 这一层负责三件事: + * 1. 把用户选的档位翻成 `interrupt` 帧的 `scope`——**默认档与「并行」档都必须是「不发」** + * (默认档在服务端视同 `"all"`,帧因此与加入 `scope` 之前逐字节相同;并行档则压根不该有这条帧, + * 它与打断是互斥语义); + * 2. 两个声明**没有哪一档同时给**(`wireScope` 与 `wireParallel` 永不同时成立); + * 3. 落盘那一侧(`AppSettings` 的 `interrupt_mode` 键,按 `enum.name` 存)三档都能存能读、 + * 未知值仍回落默认档。`AppSettings` 本体要 Android + DataStore、单测里起不来,映射本身是顶层 + * 纯函数 [toInterruptMode]。 */ class InterruptModeTest { @@ -22,7 +30,7 @@ class InterruptModeTest { val mode = AppPrefs().interruptMode assertEquals(InterruptMode.AppendQueue, mode) - assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope) + assertNull("默认档必须是「不发打断帧」", mode.wireScope) assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode)) } @@ -36,23 +44,63 @@ class InterruptModeTest { } @Test - fun `the preference only ever produces the two documented wire values`() { - // 服务端取值域是 {"", "all", "current"};客户端只发「省略」与 "current" 两种, - // ""/"all" 已由省略表达——不自己补一串等价别名,少一种线上形状就少一类歧义。 - assertEquals(listOf(null, "current"), InterruptMode.entries.map { it.wireScope }) + fun `the parallel mode sends no interrupt frame at all`() { + // 并行与打断互斥:并行档先打断就恰好砍掉它要并行的那一路。所以这里不是「省略 scope」 + // 而是「这条路根本不发这条帧」——`wireScope` 就是 `null`,帧形状与默认档那个「不发」一致。 + assertNull(InterruptMode.Parallel.wireScope) + assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(InterruptMode.Parallel)) + } + + @Test + fun `the preference only ever produces the documented interrupt wire values`() { + // 服务端取值域是 {"", "all", "current"};客户端只发「不发打断帧」与 "current" 两种, + // ""/"all" 由「不发打断帧」表达——不自己补一串等价别名,少一种线上形状就少一类歧义。 + assertEquals(listOf(null, "current", null), InterruptMode.entries.map { it.wireScope }) assertTrue(InterruptMode.entries.none { it.wireScope == "all" }) assertTrue(InterruptMode.entries.none { it.wireScope == "" }) } @Test - fun `both modes stay distinguishable on the wire`() { - // 打成同一个形状就等于「两种模式被当成一种」——正是 A2 要修掉的那类缺陷。 - assertTrue(frameOf(InterruptMode.AppendQueue) != frameOf(InterruptMode.Interrupt)) + fun `no mode ever asks for both an interrupt and a parallel leg`() { + // 一对互斥语义:打断 = 先取消她正在说的这一句;并行 = 不打断、另起一路同时说。 + // 两件事同时声明是自相矛盾的(先砍掉要并行的那一路),三档各占一种组合,没有第三种。 + val names = InterruptMode.entries.map { it.name } + + assertTrue( + "没有任何一档能同时给两个声明:$names", + InterruptMode.entries.none { it.wireScope != null && it.wireParallel }, + ) + } + + @Test + fun `only the parallel mode ever declares a parallel leg`() { + assertEquals(listOf(InterruptMode.Parallel), InterruptMode.entries.filter { it.wireParallel }) + assertFalse("默认档一个声明都不发", AppPrefs().interruptMode.wireParallel) } @Test fun `the persisted enum names are stable`() { // AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。 - assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name }) + // 第三档是**追加**上来的新名字;既有那两个名字一个字都不许动。 + assertEquals(listOf("AppendQueue", "Interrupt", "Parallel"), InterruptMode.entries.map { it.name }) + } + + @Test + fun `every mode survives a round trip through the persisted name`() { + InterruptMode.entries.forEach { mode -> + assertEquals("落盘名 ${mode.name} 读不回原来的档", mode, mode.name.toInterruptMode()) + } + // 三个落盘名必须互不相同,否则有档位会被读成另一档。 + assertEquals(3, InterruptMode.entries.map { it.name }.toSet().size) + } + + @Test + fun `an unknown persisted value still falls back to the default mode`() { + // 缺省 / 旧版本遗留 / 被手改过 / 大小写不同 / 带了空格的值,一律解成默认档:回落方向必须是 + // 「最少动作」的那一档,**绝不**悄悄换成更激进的「打断」或「并行」。 + // 注意 "current" 是 interrupt 帧的 scope 取值、不是档位名——它也必须回落。 + listOf(null, "", "all", "current", "Parallelism", "parallel", "AppendQueue ", "3").forEach { raw -> + assertEquals("落盘值 ${raw?.let { "「$it」" } ?: "空"} 应回落默认档", InterruptMode.AppendQueue, raw.toInterruptMode()) + } } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsOutboundParallelTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsOutboundParallelTest.kt index d652ddf..108f236 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsOutboundParallelTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsOutboundParallelTest.kt @@ -7,9 +7,10 @@ import org.junit.Test /** * 忙着并行规范的上行两处**纯增量**(§2.2 `chat.parallel` / §2.3 `interrupt.turnId`)。 * - * 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。本端今天一个字节都不发这两个字段 - * (真并行要用户显式声明,声明入口不在本次范围内),所以这一组测试真正钉住的是 - * 「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。 + * 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。`parallel` 现在有调用方了—— + * 设置页那栏选「并行」且会话正忙时由发送路径置 `true`(判据是纯函数 `parallelForSend`,线上取值 + * 经 `parallelWireValue` 翻,`false` 一律翻成「整键省略」);`interrupt.turnId` 本端仍一个字节都不发。 + * 所以这一组测试真正钉住的是「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。 */ class WsOutboundParallelTest { diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt index 8e253e6..aaaac08 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/InterruptScopeTest.kt @@ -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), + ) + } + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelForSendTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelForSendTest.kt new file mode 100644 index 0000000..38251ee --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelForSendTest.kt @@ -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 = 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")) + } +}