Files
AskaEth 8babd1d2bb W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要
两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。

1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
   「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
   ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
   一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
   打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
   由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
   ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。

2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
   分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
   每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
   现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
   非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
   _read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。

assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
2026-10-26 22:17:44 +08:00

37 KiB
Raw Permalink Blame History

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-For-Android/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)。

场景五:主动消息(proactive_message)实时到达

「她主动说的话」在线上有两条路:有在线客户端时走 proactive_message 帧,走 IM 桥时走 message_persisted{proactive:true}。 帧壳不带 sessionId(真后端 gateway 的 wsEvent{Type, Data} 逐字如此),所以客户端必须自己钉死主会话。

真后端没有任何 HTTP 入口能按需触发一次主动推送(backthink 只由心跳/事件决定,路由里只有一个 GET /api/v1/admin/backthink),所以本机验收只能自己造帧 —— proactive_inject.py 按真后端的顺序 (先落库、再发帧)把那一对动作做出来:

python fake_backend.py --port 18090 &

# 探针**必须带 `--send` 或 `--ping`** —— 不带时 `ws_probe.py:201` 只监听 `min(listen, 1.0)` 秒就收工,
# 连接随即关闭 ⇒ 之后广播的帧 `delivered=0`(本工具没有补发,白忙一场)
python ws_probe.py --port 18090 --send '{"type":"noop","data":{}}' --listen 8 &

# 落一行 assistant 历史行 + 广播带**同一 id** 的 proactive_message(两条腿都在这一个脚本里)
python proactive_inject.py --port 18090 --text "验收:她主动说了一句"

探针那边应当原样收到(形状出处:Cyrene_v2/internal/services/gateway/proactive.go:23-41):

{"type":"proactive_message","data":{"content":"验收:她主动说了一句","platform":"web",
 "messageId":"im-msg-000004","sender":"昔涟","id":"msg-000004","createdAt":1791243748593}}

--without-id 是另一档:帧上不带 id/createdAt,而且连历史行都不落(落了行就验不出这一档)—— 它等价于真后端「落库失败」。此时客户端只该有个本地气泡、刷新后它就不该在了, 但它绝不能被静默丢掉(「落库失败也不能让消息消失」正是这条的验收点)。

App 侧三个判据:① 主会话(web)里实时多一条助手气泡;② 切到任一次级会话 —— 不出现;③ 重进聊天页后还在,且只有一条。

8. 端点速查

8.1 语音夹具(接收侧:voice 键 + GET /api/v1/media/{name})

它解决什么问题:生产库里一条带 voice 的真消息都没有(拉 200 条 0 命中),而语音的 voice 键只在四个 REST 读取面填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端 造一条带 voice 键的历史消息。

POST /__control/messages 多两个参数(其余不变):

参数 取值 说明
voice transcribed / empty / failed / nocopy / none(默认) 见下表
duration 毫秒(默认 2000);0 0 ⇒ 不打 durationMs 键(验「缺席就不显示时长」那一档)
voice= source / status 音频副本 设备上该看到什么
transcribed asr / transcribed 有 语音气泡 + 播放键 + 时长;正文 = 你给的 text
empty asr / empty 有 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」
failed asr / failed 有 播放键照在;正文由你用 text= 给(假后端不替你编服务端那句话)
nocopy platform / transcribed 无 只有「语音 · 时长」,没有播放控件(微信 weixin-ilink 那条路)

服务端那一条:GET /api/v1/media/{name} 只认夹具名(fakevoice0000000000000000000000.wav), 回的是一段真的 2 秒 440 Hz WAV(启动时用 wave 现生成、16-bit PCM 单声道 8 kHz,不落盘), 其余名字一律 404 media_not_found。

B=http://127.0.0.1:18080

# 1) 灌一条有副本的语音(正文是转写文本)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91"

# 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4)
curl -s "$B/api/v1/sessions/web/messages?limit=1"

# 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz)
curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav"

# 4) 另外三档(各灌一条,屏幕上应看得出四档的差别)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0"   # 不显示时长

在 App 上验(App 已按第 2 节指向本工具):灌完之后重进聊天页(或用 POST /__control/ws-close 踢一次重连触发补拉),那几条语音就会以语音气泡出现。

⚠️ 本工具的 history 握手回放会把 voice 一起回放(它直接回内存里那份消息对象), 而真后端不会(合同 §4.4:history 回放不带 voice)。所以在这里「重进页面就看到语音气泡」 比真后端更容易;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段, 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。

8.2 图片夹具(灌一条带图的历史消息:image_file)

它解决什么问题:要验「点开一张图 → 全屏预览」,手里就得有一条带 image 键的历史消息; 而预览里那些从字节现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自原样的字节。 相册选择器(PickVisualMedia)会把选中项转码一遍,转码后的副本没有 EXIF(实测:一张带 DateTimeOriginal 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。

本工具不代取外链、也不起静态文件服务,所以给的是 data: URI:客户端 (ImageSource.Inline → ImageDataUri.bytes)解出来的就是文件逐字节相同的那份内容。

POST /__control/messages 多一个参数(其余不变):

参数 取值 说明
image_file 本地图片路径,扩展名限 .jpg / .jpeg / .png / .gif / .webp / .bmp 读成 base64 data: URI 打进这条消息的 image 键。扩展名不在白名单 ⇒ 400;文件为空 ⇒ 400(不退回 application/octet-stream:错的类型会让场景看起来和实际不是一回事)
B=http://127.0.0.1:18080

# 灌一条带图的消息(role=user 才长得像「自己发的一张图」)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg"

灌完之后重进聊天页,那条消息就是一个图片气泡;点它即进 ImagePreviewDialog。

⚠️ data: 只适合小图:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。 40 KB 的图没问题,别拿 20 MB 的来灌。

⚠️ 若 App 本机已经有一份本地历史(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到: 要让「服务端历史」成为唯一真相,得清 App 数据重登(adb shell pm clear top.yeij.cyrene)—— 这一步会连登录态一起清掉,动手前先想清楚。

8.3 纯附件 / 无正文消息(documents= + text= 留空)

它解决什么问题:纯附件消息(documents 非空、content 为空)这条形状以前灌不出来 —— 本工具不实现 upload / 摄取链路(§11),PATCH /api/v1/messages/{id} 又要求 content 非空。 于是「没有正文的消息」这一类(纯附件 / 纯图片 / 没转出字的语音)在本机一条都造不出, W72 修的那条「气泡外侧的时间」也就无从验收。

两个参数一起用就能造出来:

参数 取值 说明
text 空串(text=) 与「不带 text」是两件事:不带 ⇒ 沿用默认正文 seed #N;text= ⇒ 这条 content 就是 ""。带非空值时照旧(支持 {n} 占位)
documents extracted / listed / unreadable / none(默认) 往这条消息的 documents 键打一枚芯片。三档见下(none = 不打这个键,后端本来就是空即省略)
documents= status 设备上那枚芯片(文案出处 DocumentChips.statusLabel)
extracted extracted 常规色 · TXT · 12 B · 10 字 · 已读入
listed listed 常规色 · … · 只看得到清单(正文没进上下文,不是读不到)
unreadable unreadable 灰态 · … · 读不到;reason 那句原话留给附件预览弹层
B=http://127.0.0.1:18080

# 1) 纯附件消息:content 为空 + 一枚附件芯片
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=extracted"

# 2) 纯图片消息:同一个招牌的另一半(content 为空,只有 image 键)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&image_file=D:/tmp/w70/with_exif.jpg"

# 3) 没转出字的语音(正文为空、只有语音形态)
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&voice=empty"

# 4) 确认那条真的没有正文、也没被塞一个默认正文
curl -s "$B/api/v1/sessions/web/messages?limit=1"

# 5) 对照:真的空气泡(三样都没有)——它走的是 ContentlessNote 那条路,不是本夹具的重点
curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&documents=none"

灌完之后重进聊天页(或 POST /__control/ws-close 踢一次重连触发补拉)就能看到。 documents 与 voice 不同:真后端也会在握手 history 回放与 message_persisted 里带上它 (同一个 DTO,MessageDto.kt:13 那一段),所以这里回放出来没有「比真后端更容易」的问题。

⚠️ 这两个参数只是往消息上打那两个键,不是摄取链路:文件没有真的被读、正文没有真的进上下文, 所以附件预览弹层里「服务端读到的那段正文」在本机永远是空的。

/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)。⚠️ 客户端已经不消费它(W45 删掉了「先探 /health 再回落 REST」那一整套,见 SendFallback.kt:120):现在 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 /api/v1/media/{name} 语音副本播放端点(契约 §1.2 U19):只认夹具名,回 2 秒 WAV;其余 404 media_not_found。⚠️ 真后端要 Bearer owner|admin,本工具不校验鉴权 ⇒ 这里验不到鉴权那一半(见 8.1)
GET /ws/chat?token=… 手写 WebSocket;握手后立刻发 history(回放条数可配)

/__control/**(同样包信封,但载荷是本工具自己的状态,不是产品契约):

端点 作用
POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=&documents= 免费灌 N 条(text 支持 {n} 占位;text= 空串 = 这条没有正文);voice= 灌一条语音消息(见 8.1);image_file= 灌一条带图的消息(见 8.2);documents= 灌一条带附件的消息(见 8.3)
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, body}。 Authorization 只留 sha256 指纹、WS 的 ?token= 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。 body 是请求体的摘要,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成 "<N chars>",非 JSON 的体记成 {bytes, note}。这样「发了几条、每条带不带图、有没有 parallel」 读得出来,而图片的 base64 不会把一行日志撑到几 MB。body 只在有请求体时出现。

挂起的上行消息(--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
voice 键(7 个键名 / 三态 / 四者同生共死)与 GET /api/v1/media/{name} 契约:Cyrene_v2/Docs/api-client.md §4.4「voice 语义」+ §1.2 U19(v0.79 已实现)。本工具按它造夹具,不代表真后端的逐个字节
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
proactive_message 载荷(id/createdAt 与落库行同源、帧壳不带 sessionId) 后端源码:Cyrene_v2/internal/services/gateway/proactive.go:23-41 + proactive_persist.go:36。这是本工具唯一一处以真后端为准而不是以客户端为准的形状 —— 因为它正是「后端加了新键、客户端旧版静默丢弃」的那条路

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 的摄取链路(POST /upload 与「文件真的被读、 正文真的进上下文」那一段;/__control/messages 的 documents= 只是往消息上打那个键,见 8.3)、 voice_input 的落库、 mode_switch 的语义、observability_* 推送、subagent_ask / task_output_query。上行收到这些帧 只记录、不处置。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。 ⚠️ GET /api/v1/media/{name} 实现了(语音播放那一半,见 8.1),但不校验 Bearer —— 真后端那条是 owner|admin 端点(契约 U19)。
  • 主动消息只能靠 proactive_message 帧造:本工具不模拟 backthink(真后端也没有 HTTP 入口), 所以「她自己决定什么时候开口」这件服务端行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段 (见第 7 节场景五)。同理,本工具不会自动补发 message_persisted:真后端走 IM 桥的主动消息会额外 广播一条 message_persisted{proactive:true},那条路在本机需要自己造帧。
  • ws_probe.py --listen N 单独用只有 1 秒(ws_probe.py:201 是 min(listen, 1.0)):要让它守着连接 等别人广播,必须带 --send 或 --ping,否则连接会先关掉、之后所有广播都是 delivered=0。
  • message_persisted 与流帧的先后顺序是我按「正文流完再落库」安排的,没有对着真后端核过。
  • --hold-replies 的三个代价(都刻意保留,因为改掉就不再是「只关那一件事」了):
    1. POST /api/v1/chat 的回执形状逐字不变(仍 {stream:true,queued:false,queueDepth:0,messageId,sessionId}), 而那条消息此刻并没有进库 —— 这个回执是「假后端在说已受理」的近似;想如实区分,看 /__control/state 的 heldMessages。
    2. 挂起期间不认领幂等键(与「队列满的判定发生在认领之前」一致)⇒ 同一 clientMsgId 重发会得到 第二条挂起记录,而不是 chat_duplicate。
    3. createdAt 在补落库那一刻才盖 ⇒ 若期间库里已经有更晚的消息,这条会以「晚到」的方式插进去, 而 WS history 用的是列表序(不做排序)⇒ 这时的 history 顺序会与 REST 按 createdAt 的 排序不同(默认模式下两者本来就一致,所以只有挂起场景看得到这个差)。
  • 挂起的消息只活在内存里(进程一停就没了);/__control/reset 会清空挂起列表,但不动模式开关 (holdReplies 是模式,不是库里的状态)。
  • 不做 TLS(只有 http://),也不支持 WebSocket 扩展 / permessage-deflate。
  • 控制端点的响应形状({"data":{...}} 里的键)是自造的,不要照它写客户端代码。