Files
Cyrene-For-Android/devtools/fake-backend
AskaEth 86a2217858 feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
   每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
   就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。

加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
  用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
  messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
  握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
  (Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
  触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
  目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
  用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
  http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。

边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。

形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。

验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
2026-10-05 08:01:48 +08:00
..

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}
--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. 其它可编排场景(都试过)

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,两个键都能缺席)
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"

# 忙碌 / 空闲 / 已打断
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)。

7. 端点速查

/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/ws-close?code=&reason= 踢掉所有客户端 WS(用来触发客户端的重连 / 补洞路径)
POST /__control/forget / create / clear / reset 会话级造场景
GET /__control/state / upstream / requests 当前状态、上行帧、HTTP 请求流水

8. 记录文件(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 的文件。

9. 形状出处(它照谁回话)

实现 依据
信封 {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

10. 已知不确定 / 没做

  • 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 与流帧的先后顺序是我按「正文流完再落库」安排的,没有对着真后端核过。
  • 不做 TLS(只有 http://),也不支持 WebSocket 扩展 / permessage-deflate。
  • 控制端点的响应形状({"data":{...}} 里的键)是自造的,不要照它写客户端代码。