feat(ws): 队列满时定位并处置那条待发气泡(契约 v0.59 / 协同 G5)
WS 上行没有 REST 那样的同步回执:队列满时服务端只广播一条 pending_full,而载荷里既没有
「被拒的是哪一条」也没有任何可对齐的键,于是那条乐观气泡永远挂「发送中…」,用户不知道它
其实根本没发出去。后端 v0.59(G5)已经把这两件事补齐:pending_full 载荷新增两个可选键
clientMsgId(被拒那条消息的幂等键,未携带则整键缺席)与 messageId(仅当该消息此前已落库 /
已认领时才回填,服务端绝不在此新铸 id),并新增一条只发发送者的定向帧
error{code:"chat_busy"},与 REST 的 409 chat_busy 同码。本端把这两条接上。
data/ws/WsEvent
- PendingFull 增 clientMsgId / messageId:缺席就是 null,绝不拍成空串(否则收编路径会去
对齐一条不存在的消息)。
- Error 帧增 code / clientMsgId / messageId,并从 data.sessionId 取会话归属(定向帧的帧壳
上没有 sessionId)。code 是开放集合:未知码原样保留,不抛、也不吞掉整帧。
未给定向 error 另立事件类——它与既有 error 是同一个 type,一个 type 无法用两个类表达;
两者语义的分叉放在下面的判定函数里,保持「一个帧类型一个事件类」的既有约定。
- PendingFullData / ErrorData 同步扩键,全部可选且默认 null。
ui/chat/ChatViewModel
- 新增纯函数 queueFullOutcome(判定)与 ChatUiState.applyQueueFull / rollbackPending
(状态迁移),两者都不碰协程,逻辑不埋在协程里、可直接单测。既有 rollback 改为委托
rollbackPending,行为逐字不变。
- 判定顺序即证据强度:clientMsgId 命中时,messageId 非空 => 收编(复用 alignDuplicate:那条
其实早就落库了,不是失败、不回滚、不把正文/图片/附件退回输入栏),缺席 => 回滚那一条并把
内容还给输入栏;clientMsgId 有值但不命中 => 放手(键只属于某一次具体发送,回滚任何一条都是
误伤);clientMsgId 缺席(老服务端)=> 沿用 toolExecutionTargetIndex 的先例,只在待发恰好
一条时回滚它,两条及以上不猜、但也不装作没事,只如实提示一句。
- 广播 pending_full 与定向 error{chat_busy} 会同时到达发送者,两者共用 onQueueFull;
幂等靠状态本身——第一次已把气泡移出待发列表,第二次判定自然落到 Ignore,不会回滚两次、
也不会提示两遍。
- REST 409 chat_busy 的文案与 WS 侧合并成同一句(契约把两条入口定为同一个 code),
并点明「这条没发出去、请手动重试」:全链路没有自动重试。
测试 +30(634 -> 664,0 失败 / 0 错误)
- WsEventPendingFullTest:两个新键的有/无、老形状仍可解析、会话闸、无载荷与坏帧不抛。
- WsEventErrorFrameTest:chat_busy 定向帧全字段、可选键缺席为 null、未知 code 原样保留、
只带 code 的帧仍可解、只有 message 的老帧行为不变、坏帧不抛。
- QueueFullOutcomeTest:三种事实各自的判定与状态迁移(收编不动输入栏 / 回滚归还正文图片附件 /
不猜时一个气泡都不动),以及键对不上、空键、同一帧处理两次的幂等。
未验证:本端没有设备也没打过真后端,队列满的实机触发路径见交接报告;VM 里
「判定 -> 既有动作」的映射只由编译与纯函数单测覆盖(构造 ChatViewModel 需要 Android
环境的 AppContainer,单测里没有先例)。
This commit is contained in:
1 parent
8633ec9f06
commit
3b4a790e30
5 files changed
+736
-32
No files matched your search
@@ -280,11 +280,26 @@ sealed class WsEvent {
|
||||
override val type: String get() = "session_interrupted"
|
||||
}
|
||||
|
||||
/** 单会话 pending 达上限(`max`,默认 5),拒绝入队。 */
|
||||
/**
|
||||
* 单会话 pending 达上限(`max`,默认 5),拒绝入队。
|
||||
*
|
||||
* 契约 v0.59(协同 `G5`)在载荷上增**两个可选键**,两个都可能**整键缺席**:
|
||||
* - [clientMsgId] = **被拒那条消息**的客户端幂等键(那条消息没带键时缺席,**绝不伪造**);
|
||||
* - [messageId] = 该消息**此前已落库 / 已认领**时的 id(未被认领时缺席)。
|
||||
*
|
||||
* 服务端**绝不在这里新铸 id**:队列满的判定发生在幂等键**认领之前**,代铸一个 id 会让客户端
|
||||
* 拿同一个 `clientMsgId` 重试时被判成「重复到达」而永远进不来。于是 [messageId] 非空恰好
|
||||
* 意味着「**这条其实早就进去了**」(它是重发撞上满队列)——消费方必须收编,**不是**回滚。
|
||||
*
|
||||
* 该帧是**广播**(每条已认证连接都会收到,含 `viewer`),所以 [clientMsgId] 缺席时**无法
|
||||
* 判定被拒的是不是本端那条气泡**;判定规则见 `ChatViewModel.queueFullOutcome`。
|
||||
*/
|
||||
data class PendingFull(
|
||||
override val sessionId: String,
|
||||
val mode: String,
|
||||
val max: Int,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
@@ -436,9 +451,27 @@ sealed class WsEvent {
|
||||
override val type: String get() = "proactive_message"
|
||||
}
|
||||
|
||||
/** 服务端错误(非法 JSON / 未知类型 / 语音·子代理·任务错误等)。 */
|
||||
/**
|
||||
* 服务端错误帧。
|
||||
*
|
||||
* 它是**定向**的(只回请求那条连接,不广播),因此**不进角色门**;帧壳通常**不带**
|
||||
* `sessionId`(会话归属在 `data.sessionId` 里,见 [resolveSessionId])、也不带
|
||||
* `turnId` / `seq`——它不属任何轮次。
|
||||
*
|
||||
* [code] 是**机器可读**的业务码,取值是**开放集合**(契约 v0.59/G5 起至少含 `chat_busy`,
|
||||
* 与 REST 的 `409 chat_busy` **同码**,客户端一个 code 覆盖两条入口;另有 `bad_request` 等)。
|
||||
* 未知码**原样保留**:不抛、不让整帧消失,只是客户端不做语义处置——[message] 照常透出。
|
||||
*
|
||||
* [clientMsgId] / [messageId] 只在 `code == "chat_busy"`(队列满的定向伴生帧,
|
||||
* `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`)
|
||||
* 时有意义:[clientMsgId] = 被拒那条消息的幂等键,[messageId] 仅当它此前已落库 / 已认领时
|
||||
* 才出现(与 [PendingFull] 同一份键,语义也相同)。其余错误码下它们缺席。
|
||||
*/
|
||||
data class Error(
|
||||
val message: String,
|
||||
val code: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
override val sessionId: String? = null,
|
||||
@@ -556,8 +589,17 @@ sealed class WsEvent {
|
||||
.let { SessionIdle(resolveSessionId(sessionId, it.sessionId), it.mode, turnId, seq) }
|
||||
"session_interrupted" -> decode<InterruptedData>(data)
|
||||
.let { SessionInterrupted(resolveSessionId(sessionId, it.sessionId), it.mode, it.dropped, turnId, seq) }
|
||||
"pending_full" -> decode<PendingFullData>(data)
|
||||
.let { PendingFull(resolveSessionId(sessionId, it.sessionId), it.mode, it.max, turnId, seq) }
|
||||
"pending_full" -> decode<PendingFullData>(data).let {
|
||||
PendingFull(
|
||||
resolveSessionId(sessionId, it.sessionId),
|
||||
it.mode,
|
||||
it.max,
|
||||
it.clientMsgId,
|
||||
it.messageId,
|
||||
turnId,
|
||||
seq,
|
||||
)
|
||||
}
|
||||
// 幂等回执(v0.56 §3.13):帧壳带 sessionId,data 内也带一份(两者取有值者)。
|
||||
"chat_duplicate" -> decode<ChatDuplicateData>(data)
|
||||
.let {
|
||||
@@ -584,7 +626,18 @@ sealed class WsEvent {
|
||||
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
|
||||
)
|
||||
}
|
||||
"error" -> Error(decode<ErrorData>(data).message, turnId, seq, sessionId)
|
||||
// 定向错误帧(含队列满的伴生帧):编码是开放集合,原样带上,未知码不做语义处置。
|
||||
"error" -> decode<ErrorData>(data).let {
|
||||
Error(
|
||||
it.message,
|
||||
it.code,
|
||||
it.clientMsgId,
|
||||
it.messageId,
|
||||
turnId,
|
||||
seq,
|
||||
sessionId ?: it.sessionId,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -669,8 +722,18 @@ private data class PendingData(val sessionId: String = "", val mode: String = ""
|
||||
@Serializable
|
||||
private data class InterruptedData(val sessionId: String = "", val mode: String = "", val dropped: Int = 0)
|
||||
|
||||
/**
|
||||
* `pending_full` 载荷:`{sessionId, mode, max, clientMsgId?, messageId?}`。前三个键来自加入
|
||||
* `G5` 之前,逐字未变;后两个是 v0.59 的纯增量,服务端**整键缺席**而不是填空串。
|
||||
*/
|
||||
@Serializable
|
||||
private data class PendingFullData(val sessionId: String = "", val mode: String = "", val max: Int = 0)
|
||||
private data class PendingFullData(
|
||||
val sessionId: String = "",
|
||||
val mode: String = "",
|
||||
val max: Int = 0,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
)
|
||||
|
||||
/** `chat_duplicate` 载荷(v0.56 §3.13):`{sessionId, clientMsgId, messageId}`。 */
|
||||
@Serializable
|
||||
@@ -715,5 +778,16 @@ private data class ProactiveData(
|
||||
val messageId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `error` 帧载荷。`code` / `sessionId` / `clientMsgId` / `messageId` 都是 v0.59 起的可选键
|
||||
* (队列满的定向伴生帧带的正是这四个加 `message`),其余错误帧只带 `message`,
|
||||
* 甚至**一个字段都不带**(如 `{code:"bad_request"}`)——都按「拿不到就不做事」解码。
|
||||
*/
|
||||
@Serializable
|
||||
private data class ErrorData(val message: String = "")
|
||||
private data class ErrorData(
|
||||
val message: String = "",
|
||||
val code: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val clientMsgId: String? = null,
|
||||
val messageId: String? = null,
|
||||
)
|
||||
Reference in new issue
Block a user