主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点 验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。 ## 为什么先探测再决定,而不是直接排队 网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发 Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成 「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。 ## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt) 1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事 仍然是 sendChat,前面不加任何判断、不挂任何起点。 2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律 按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走 REST);不通 → 进队列。 3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误 (证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。 4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。 Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的), 而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会 发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。 ## 为什么重发是安全的 回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。 服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate / duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经 收下」这种最难判的情况不需要本端猜。 ## 回复怎么回来 那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp → loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以 回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。 ## 其他 - 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测 都不发。 - 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上 所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论 之后。 - 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒); 回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。 - 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、 探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。 - devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
24 KiB
Cyrene 假后端(devtools/fake-backend/)
一个可编程的假后端:用来把「需要特定服务端行为才验得到」的客户端行为变成可复现的测试。 纯 Python 3 标准库,零第三方依赖,不需要 Gradle、不需要模拟器里的真后端。
它不是产品的一部分,也不是后端行为的权威来源 —— 所有响应形状都是从客户端代码里读出来的 (出处逐条列在下面「形状出处」一节)。它照我们读出来的形状回话,不代表真后端真的这么回。
它不做什么(这条比它能做什么更重要):
- 不校验任何鉴权。
Authorization/X-API-Version照收不误、一律放行;收收到什么头都会记进out/http.jsonl。 - 不落盘成任何持久数据(只有
out/*.jsonl的流水日志)。进程一停,消息全没了。 - 不调模型。回复正文是你在控制端点里给的字符串按字符切片切出来的,没有任何生成。
- 不实现契约的全集。只实现「启动到渲染」要用到的那一段(见端点表),其余一律 404。
- 不要拿它当后端行为的事实来源。要判断真后端怎么回,去看
Cyrene_v2/Docs/api-client.md与真后端本身; 这个工具的价值是「造场景 + 记录客户端发了什么」,不是「回答后端是什么」。
1. 起服务(Windows + Git Bash)
cd /d/Project/Code/Uni/Cyrene-Android-FB/devtools/fake-backend
python fake_backend.py --port 18080
启动后会打印监听地址、回放条数、回复形状、记录目录,然后原地等连接(Ctrl-C 退出)。
端口随便挑,别用 10942 —— 那是真后端的端口,占用或干扰它会打断另一个会话的联调。
常用开关:
| 开关 | 默认 | 作用 |
|---|---|---|
--port N |
18080 |
监听端口 |
--host H |
127.0.0.1 |
监听地址 |
--history-replay N |
50 |
WS 握手 history 帧只回放最近 N 条(比库里的条数小就是那个洞) |
--stream-chunk |
关 | 回复走旧形状 stream_chunk + stream_end(验客户端的回退分支) |
--auto-reply |
关 | 收到上行 chat 帧后自动回一轮(默认关,保持确定性) |
--interrupt-ack |
关 | 收到上行 interrupt 帧后回 session_interrupted{mode:"idle",dropped:0} |
--hold-replies |
关 | 上行消息只记录、不落库:收到 chat / POST /api/v1/chat 后不广播 message_persisted,由 POST /__control/persist 显式补落库(见第 6 节) |
--out-dir DIR |
<脚本目录>/out |
记录目录 |
2. 客户端怎么指过来
adb reverse tcp:18080 tcp:18080
然后在 App 的登录页(「服务器地址」一栏,LoginViewModel.kt:57)填:
http://127.0.0.1:18080
登录随便填(用户名 / 密码都不校验)。adb reverse 把模拟器内的 127.0.0.1:18080 转到 PC 上的假后端,
所以 REST 与 ws://…/ws/chat?token=… 走的是同一个地址,App 侧不用做任何特殊配置。
想从干净状态重来:设置里退出登录 / 清 App 数据,再打一次 adb reverse(它只跟着设备会话活)。
3. 场景一:after 正向补洞
要验什么:WS 重连只回放最近 50 条,断线期间漏掉的那一段由 GET /messages?after= 补回来
(客户端 ChatViewModel.catchUp → MessageRepository.loadAfter,契约 §1 行 6)。
用真后端要灌 51 条消息、每条跑一次模型回合;这里灌 120 条是免费且瞬时的。
# 1) 库里灌 120 条(不调模型,直接进内存)
curl -s -X POST 'http://127.0.0.1:18080/__control/messages?session=web&count=120'
# 2) 看握手到底回放了什么:只剩最近 50 条(msg-000071..msg-000120),前 70 条是洞
python ws_probe.py --port 18080 --listen 1
# 3) 断线前「本端最后看到的那条」——它才是客户端 gapCursor 的来源
curl -s 'http://127.0.0.1:18080/__control/message?session=web&index=20'
# 4) 用它的 createdAt 作 after 游标补洞:第 1 页给回来的正好是那段洞
curl -s 'http://127.0.0.1:18080/api/v1/sessions/web/messages?limit=50&after=<上一步的 createdAt>'
# 5) 用「本页最后一条的 createdAt」继续推进,直到返回空数组(客户端就是这么推进的)
在 App 上跑同一条路(真正的端到端):
# App 已经连着假后端时,把它踢下线;客户端会重连,重连触发 catchUp
curl -s -X POST 'http://127.0.0.1:18080/__control/ws-close?code=1001'
# 客户端到底发了哪些 after 请求?看请求流水:
grep -o '"after=[0-9]*"' devtools/fake-backend/out/http.jsonl
注意客户端只在「曾经 Connected 过、然后断开、再连上」时才做这次正向补拉
(ChatViewModel.kt:634-646),冷启动走的是首屏 REST,不动 after。
4. 场景二:打断帧(interrupt)
要验什么:设置档位 = 「打断」且会话正忙时,客户端在发消息之前会先发
interrupt{scope:"current"}(InterruptScope.kt:27、ChatViewModel.kt:1058-1063)。
真后端不给你看上行帧,这里看得到,而且这是唯一的第一现场。
# 1) 起服务(回执可选:开了之后打断会立刻回到 idle,方便看界面状态)
python fake_backend.py --port 18080 --interrupt-ack
# 2) 让主会话忙起来 —— 客户端收到 session_busy 才会认为「正忙」
curl -s -X POST 'http://127.0.0.1:18080/__control/busy?session=web&mode=thinking'
# 3) App 里:设置 → 打断档;然后在聊天页再发一条消息
# 4) 证据(每条上行帧一行,含时间戳)
tail -n 2 out/upstream.jsonl
# 也可以用一个不依赖 App 的等价动作,证明记录链路本身是通的:
python ws_probe.py --port 18080 --listen 2 \
--send '{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}'
5. 场景三:stream_chunk 回退路径
要验什么:契约 §3.1 的「非流式回退路径」——整段正文只在 stream_chunk + stream_end 里,
没有 message.start/delta/end。真后端可能永远不走那条路,这里可以指定走。
# 1) 起服务,指定回退形状
python fake_backend.py --port 18080 --stream-chunk
# 2) 触发一轮回复(不需要在 App 里打字;text 用 URL 编码传中文)
curl -s -X POST 'http://127.0.0.1:18080/__control/reply?session=web&text=hello&chunks=3'
# 3) 想在连接上原样看到这几帧:
python ws_probe.py --port 18080 --listen 4
想临时切回结构化形状,不改启动参数也行:…/__control/reply?session=web&text=hi&frame=structured。
6. 场景四:忙时被拒 —— 让「待发气泡还挂着」可复现(--hold-replies)
要验什么:客户端手里还挂着一条没发出去的气泡时,收到「队列满 / 忙时被拒」会怎样。
判据是客户端的纯函数 queueFullOutcome(ChatViewModel.kt:240),三档:
- 本会话待发 0 条 ⇒ 刻意什么都不说(没有气泡会挂住,不能凭空弹提示);
- 恰好 1 条 ⇒ 回滚:气泡摘掉、正文 / 图片 / 附件退回输入栏,另加一条横幅;
- ≥2 条 ⇒ 不猜:一个气泡都不动,横幅只说「可能没发出去」(
QUEUE_FULL_UNKNOWN_MESSAGE)。
为什么需要这个开关:默认行为下,客户端一发上行 chat(或 POST /api/v1/chat),假后端立刻
把那条用户消息落库并广播 message_persisted;客户端拿 clientMsgId 一比就把乐观气泡收走
(consumePendingFor,ChatViewModel.kt:116)。一个往返之后待发列表就空了 —— 于是「服务端把这条
拒掉」这类场景永远凑不出来:手里根本没有气泡可回滚,判定只会落到「0 条 ⇒ 静默」那一档。
真后端忙时不会立刻落库(先排队或先拒绝),所以这是假后端的建模缺陷,这个开关就是修它。
它做什么:--hold-replies 打开后,收到上行消息只记录、不落库、不广播 message_persisted:
- 上行帧照常进
out/upstream.jsonl,/__control/state的wsUpstreamByType照常计数(判据来源不变); - 回复路径不受影响:
--stream-chunk/--auto-reply//__control/reply自己的persist=该怎么走还怎么走 —— 那是另一个开关,关的是「上行那条的落库」这一件事; - 那条消息挂进
state.heldMessages(带holdId/ 先占的messageId/clientMsgId/heldAt), 由POST /__control/persist显式补上落库那一步。
于是场景可以分两段做:「先把气泡挂住」→「再落库,或者干脆发一帧拒绝」。运行期也能开关:
POST /__control/hold-replies?on=1|0(等价于启动参数,改完立刻生效)。
B=http://127.0.0.1:18099
# 1) 起服务(换个端口,方便和默认那一档并排)
python fake_backend.py --port 18099 --hold-replies
# 2) 发一条 chat:它会被挂住 —— 「挂住」的判据是 2 秒里一条回帧都没有
python ws_probe.py --port 18099 --listen 2 \
--send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-1","text":"这条先挂着"}}'
curl -s "$B/__control/state" # heldMessages 里有它;wsUpstreamByType.chat=1
grep -c message_persisted out/downstream.jsonl # 0:一条都没广播
# 3) 显式补落库 —— 这时才广播 message_persisted
# 先在一个终端起探针(它监听 5 秒):
python ws_probe.py --port 18099 --ping --listen 5
# 再在另一个终端补落库(回执里的 delivered 应当 >= 1):
curl -s -X POST "$B/__control/persist?id=local-1"
# 4) 忙时被拒:再发一条(local-2)挂着,然后注入 pending_full —— 这是「1 条待发」那一档
# 还是分两个终端:探针先发 chat 再监听 6 秒,把那段时间留给另一个终端注入
python ws_probe.py --port 18099 --listen 6 \
--send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-2","text":"这条会被拒"}}'
# 另一个终端注入(探针还在监听时执行,回执里的 delivered 应当是 1):
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-2"
# 探针那边就能看到那帧原样到达:
# <<< frame 1 [after-send] {"type":"pending_full", …
# 客户端期望:气泡回滚、正文回到输入栏、横幅是 QUEUE_FULL_MESSAGE
# 「她正忙且队列已满,这条没发出去,请稍后手动重试」(ChatViewModel.kt:138)
在 App 上跑同一条路:带 --hold-replies 起服务、客户端指过来,在聊天页发一条,然后
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5"(不带 clientMsgId,
走的就是「1 条待发」那一档:本会话只有这一条在飞,判定无歧义)。想让帧精确指向那条气泡,就把键
填成客户端铸的 localId —— 它就在 out/upstream.jsonl 那条 chat 帧上(data.clientMsgId)。
帧上带不带那两个可选键,客户端的处置完全不同(与 queueFullOutcome 的判定顺序一一对应):
| 帧上的键 | 客户端看到的效果 |
|---|---|
&clientMsgId=<那条 localId>,不带 messageId |
命中那条气泡 ⇒ 回滚 + 横幅(本场景要验的就是它) |
&clientMsgId=<那条 localId>&messageId=m-1 |
收编:只摘掉气泡,正文不退回输入栏(「其实早就进去了」) |
不带 clientMsgId,本会话 1 条待发 |
就是它 ⇒ 回滚 + 横幅 |
不带 clientMsgId,本会话 ≥2 条待发 |
不猜:一个气泡都不动,横幅只说「可能没发出去」 |
不带 clientMsgId,本会话 0 条待发 |
什么都不说(刻意静默) |
定向那一帧同理,与广播帧是同一个 code、同一句文案:
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-2"
同一件事被两条帧各报一次(广播 pending_full + 定向 error{chat_busy})时,第二次到达气泡早已摘走,
客户端不会重复归还(QueueFullOutcomeTest 钉住了这条)。
7. 其它可编排场景(都试过)
B=http://127.0.0.1:18080
# 会话在服务端没了 → 404 session_not_found(客户端据此置 sessionGone,ChatViewModel.kt:1808)
curl -s -X POST "$B/__control/forget?session=web:5eed0000000000000000000000000001"
curl -s "$B/api/v1/sessions/web:5eed0000000000000000000000000001/messages?limit=1"
# 队列满:广播 pending_full(可选带 clientMsgId / messageId,两个键都能缺席)
# 忙时被拒的完整打法(先让气泡挂着再拒)见第 6 节
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-zz"
# 定向错误帧(与 REST 的 409 chat_busy 同码)
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-zz"
# 上行落库挂起 / 恢复,以及显式补落库(第 6 节那两个端点)
curl -s -X POST "$B/__control/hold-replies?on=1"
curl -s -X POST "$B/__control/hold-replies?on=0"
curl -s -X POST "$B/__control/persist?session=web&id=local-zz"
# 忙碌 / 空闲 / 已打断
curl -s -X POST "$B/__control/busy?session=web&mode=executing"
curl -s -X POST "$B/__control/idle?session=web&mode=idle"
curl -s -X POST "$B/__control/interrupted?session=web&mode=idle&dropped=0"
# 任意下行帧(整帧原样广播,形状不对时客户端会安全忽略 —— 正好用来验「坏帧不崩」)
curl -s -X POST "$B/__control/frame?json=%7B%22type%22%3A%22session_idle%22%2C%22sessionId%22%3A%22web%22%2C%22data%22%3A%7B%22mode%22%3A%22idle%22%7D%7D"
# 编辑 / 删除单条消息(REST 回执 + 广播帧都会来)
curl -s -X PATCH "$B/api/v1/messages/msg-000001" -H 'Content-Type: application/json' -d '{"content":"edited"}'
curl -s -X DELETE "$B/api/v1/messages/msg-000002"
# 已读游标(推进后 unreadCount 归零,并广播 session_read)
curl -s -X POST "$B/api/v1/sessions/web/read" -H 'Content-Type: application/json' -d '{}'
# 会话列表 / 改名 / 置顶 / 新建 / 清空 / 复位
curl -s "$B/api/v1/sessions"
curl -s -X PATCH "$B/api/v1/sessions/web" -H 'Content-Type: application/json' --data-binary @body.json
curl -s -X POST "$B/__control/create?session=web:mine&type=sub&pinned=1"
curl -s -X POST "$B/__control/clear?session=web"
curl -s -X POST "$B/__control/reset" # 会话/消息/上行帧记录回到种子状态(out/*.jsonl 不动)
# 看状态 / 看上行帧 / 看 HTTP 请求流水
curl -s "$B/__control/state"
curl -s "$B/__control/upstream?limit=20"
curl -s "$B/__control/requests?limit=20"
控制端点响应里的
delivered= 这一刻连着 WS 的客户端数。它是广播(没有离线队列、没有补发), 所以「App 还没连上 / 已经断开」时是0而帧不会留到下次。想让 App 收到,先确认GET /__control/state里的wsClients不为 0。
Git Bash 的坑:命令行里的中文会被转成本地代码页(GBK)再交给子进程,服务端按 UTF-8 解 ⇒ 400
invalid json body。 所以带中文的请求体写进文件再用--data-binary @file,控制端点的参数用 URL 编码(%E4%BD%A0%E5%A5%BD)。
8. 端点速查
/api/v1/**(一律包信封:成功 {data} / 失败 {error:{code,message}},ApiEnvelope.kt:8):
| 端点 | 备注 |
|---|---|
POST /api/v1/auth/login /refresh /logout |
回假 token(role:"owner",expiresAt 是 Unix 秒) |
GET /api/v1/health |
存活探针:回 {"status":"ok"}。不鉴权、不做任何重活(对齐真后端 registerPublicRoutes)。客户端 W41 起在「WS 未就绪」时先探它一次,通就回落 REST 发那条消息;缺了它,那条阶梯在本机就退化成「直接排队」 |
GET /api/v1/sessions |
排序照真契约:pinned DESC, type='main' DESC, created_at ASC;unreadCount 按已读游标算 |
POST /api/v1/sessions |
可绑 personaId(未知角色 → 400);支持完全不带 body |
PATCH /api/v1/sessions/{id} |
局部响应;{} → 400;personaId → 400;title trim 后空 → 400;>80 字 → 400 |
DELETE /api/v1/sessions/{id} |
web → 403 |
GET /api/v1/sessions/{id}/messages |
支持 limit(默认 50 / 上限 200)/ before(倒序)/ after(升序)/ q / platform;after×before、after×q → 400 |
POST /api/v1/sessions/{id}/read |
游标只前进;成功后广播 session_read |
GET /api/v1/meta/enums |
载荷逐字取自客户端仓库的线上夹具 |
GET /api/v1/personas |
用户侧角色目录(不含 admin 专属 thinking) |
PATCH / DELETE /api/v1/messages/{id} |
编辑 / 删除单条,并广播 message_edited / message_deleted |
POST /api/v1/chat |
额外实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + ChatAckDto 形状 |
GET /ws/chat?token=… |
手写 WebSocket;握手后立刻发 history(回放条数可配) |
/__control/**(同样包信封,但载荷是本工具自己的状态,不是产品契约):
| 端点 | 作用 |
|---|---|
POST /__control/messages?session=&count=&role=&text=&prefix=&platform= |
免费灌 N 条(text 支持 {n} 占位) |
GET /__control/message?session=&index= |
取第 n 条(1 基),脚本里用来精确拿游标 |
POST /__control/busy / idle / interrupted |
下发 session_busy / session_idle / session_interrupted |
POST /__control/reply?session=&text=&chunks=&frame=&persist= |
下发一轮助手回复(frame=default|chunk|structured) |
POST /__control/pending-full / error / frame |
队列满、定向错误帧、任意帧 |
POST /__control/hold-replies?on= |
运行期开关:上行消息要不要挂起(等价于 --hold-replies) |
POST /__control/persist?session=&id=&index= |
把挂起的上行消息显式落库并广播 message_persisted;不给选择器就按 FIFO 落最旧那条 |
POST /__control/ws-close?code=&reason= |
踢掉所有客户端 WS(用来触发客户端的重连 / 补洞路径) |
POST /__control/forget / create / clear / reset |
会话级造场景 |
GET /__control/state / upstream / requests |
当前状态、上行帧、HTTP 请求流水 |
9. 记录文件(out/,每次启动追加,删掉即清空)
out/upstream.jsonl—— 每条上行帧一行:{ts, epochMs, client, type, sessionId, raw, frame}。raw是客户端发来的原样文本。「客户端到底发没发那一帧」以这里为准。out/downstream.jsonl—— 每条下发帧:{ts, to, origin, raw}。origin会写清是history、control:reply还是某次广播。out/http.jsonl—— 每条 HTTP 请求:{method, path, query, headers}。Authorization只留 sha256 指纹、WS 的?token=同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
挂起的上行消息(--hold-replies)不进文件 —— 它只活在内存里,看 GET /__control/state 的
heldMessages。out/*.jsonl 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
10. 形状出处(它照谁回话)
| 实现 | 依据 |
|---|---|
信封 {data} / {error:{code,message}} |
app/.../data/api/ApiEnvelope.kt:8,18 |
鉴权头 X-API-Version: 1 + Authorization: Bearer … |
app/.../data/api/ApiClientFactory.kt:115-118(只记录,不校验) |
WS 地址 GET /ws/chat?token=<jwt> |
app/.../data/ws/ChatSocket.kt:287-295 |
LoginResponse(expiresAt 是 Unix 秒、refreshToken 可缺) |
app/.../data/api/dto/LoginDto.kt:12-19 |
SessionSummaryDto(title / lastMessage / personaId 可整键缺席) |
app/.../data/api/dto/SessionDto.kt:16-50 |
会话排序 pinned DESC, main DESC, created_at ASC |
SessionDto.kt:8-9 + 线上夹具 SessionDtoRealPayloadTest.kt:36-38 |
HistoryMessageDto |
app/.../data/api/dto/MessageDto.kt:13-46 |
after 升序 / before 倒序 / q 与 after 互斥 |
app/.../data/api/SessionsApi.kt:71-101 + MessageRepository.kt:79-101 |
| limit 默认 50 / 上限 200 | app/.../data/repository/MessageRepository.kt:149-150 |
EnumTableDto 的 6/5/9 张表与 unknown:"display-raw" |
线上夹具 MetaDtoRealPayloadTest.kt:24(逐字) |
UserPersonaDto(用户侧不含 thinking) |
PersonaDto.kt:16-26 + PersonaDtoDecodingTest.kt:32 |
404 {code:"session_not_found"} |
ChatViewModel.kt:1802-1808 |
session_read 载荷只有 readAt |
WsEvent.kt:338-356 |
message_edited 的 data 就是消息本体(没有 message 包一层) |
WsEvent.kt:676-680 |
message_persisted / channel_persisted 的 data:{message} |
WsEvent.kt:671-674 |
chat_duplicate / pending_full 的可选键整键缺席 |
WsEvent.kt:291-336, 798-809 |
interrupt{scope} 取值与「只取消在跑回合」 |
WsOutbound.kt:59-78 + InterruptScope.kt:27 |
stream_chunk + stream_end 回退形状 |
WsEvent.kt:205-228 + ChatViewModel.kt:1451-1472 |
11. 已知不确定 / 没做
history帧壳:我发了{type, sessionId:"web", seq, data:{messages}}。契约说 v0.36 起每个结构化流帧的帧壳 都带sessionId(WsEvent.kt:21-27),但客户端对history不读帧壳(ChatViewModel.kt:1410-1412明确绕过isForSession,改用每条消息自己的sessionId)。所以这一处就算和真后端不一致,也不影响本工具能验的东西。platform过滤是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传null(MessageRepository.kt:42,99),所以这条路径没被真正走过。q搜索是 ASCII 大小写不敏感的子串匹配;%/_/\不做通配(我根本不解析通配符)。- 不实现:admin 面全部端点、
documents/upload、voice_input的落库、mode_switch的语义、observability_*推送、subagent_ask/task_output_query。上行收到这些帧只记录、不处置。因此 App 里 管理员页面、附件上传这类路径在这个假后端上跑不通。 message_persisted与流帧的先后顺序是我按「正文流完再落库」安排的,没有对着真后端核过。--hold-replies的三个代价(都刻意保留,因为改掉就不再是「只关那一件事」了):POST /api/v1/chat的回执形状逐字不变(仍{stream:true,queued:false,queueDepth:0,messageId,sessionId}), 而那条消息此刻并没有进库 —— 这个回执是「假后端在说已受理」的近似;想如实区分,看/__control/state的heldMessages。- 挂起期间不认领幂等键(与「队列满的判定发生在认领之前」一致)⇒ 同一
clientMsgId重发会得到 第二条挂起记录,而不是chat_duplicate。 createdAt在补落库那一刻才盖 ⇒ 若期间库里已经有更晚的消息,这条会以「晚到」的方式插进去, 而 WShistory用的是列表序(不做排序)⇒ 这时的history顺序会与 REST 按createdAt的 排序不同(默认模式下两者本来就一致,所以只有挂起场景看得到这个差)。
- 挂起的消息只活在内存里(进程一停就没了);
/__control/reset会清空挂起列表,但不动模式开关 (holdReplies是模式,不是库里的状态)。 - 不做 TLS(只有
http://),也不支持 WebSocket 扩展 / permessage-deflate。 - 控制端点的响应形状(
{"data":{...}}里的键)是自造的,不要照它写客户端代码。