Files
Cyrene-For-Android/devtools/fake-backend/README.md
T
AskaEth c73dd532ff feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。

加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
  上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
  (--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
  关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
  不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
  认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
  的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。

三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
  挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
  开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
  reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。

拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。

默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。

验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。

README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
2026-10-05 10:23:09 +08:00

367 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```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. 客户端怎么指过来
```bash
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 条是**免费且瞬时**的。
```bash
# 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 上跑同一条路**(真正的端到端):
```bash
# 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`)。
真后端不给你看上行帧,这里**看得到**,而且这是唯一的第一现场。
```bash
# 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`。真后端可能永远不走那条路,这里可以**指定**走。
```bash
# 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`(等价于启动参数,改完立刻生效)。
```bash
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、同一句文案**:
```bash
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-2"
```
同一件事被两条帧各报一次(广播 `pending_full` + 定向 `error{chat_busy}`)时,第二次到达气泡早已摘走,
客户端不会重复归还(`QueueFullOutcomeTest` 钉住了这条)。
## 7. 其它可编排场景(都试过)
```bash
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/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` 的三个代价**(都刻意保留,因为改掉就不再是「只关那一件事」了):
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":{...}}` 里的键)**是自造的**,不要照它写客户端代码。