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 个测试类)。
This commit is contained in:
AskaEth committed 2026-10-26 22:17:44 +08:00
1 parent 4d25d9ec69
commit 8babd1d2bb
4 files changed
+147 -8

No files matched your search

@@ -228,6 +228,31 @@ object ShareRules {
fun truncatedImagesNotice(kept: Int, dropped: Int): String? = fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
/**
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
*
* ## 两个事实合起来只剩两种答案
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
* (后端口径 `02` F4:他们那边不需要做任何事)。
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
* 这样的意思。
*
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
*
* ## 为什么提成一个纯函数
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
*
* @param fromShare 这条(这一批)是不是分享进来的
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
*/
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
/** /**
* 从分享 Intent 的原始字段提取内容。 * 从分享 Intent 的原始字段提取内容。
* *
@@ -0,0 +1,58 @@
package top.yeij.cyrene.domain.share
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 批量投递的「声明」边界([ShareRules.declaresSendPreference])。
*
* 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图
* 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、
* `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打
* 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。
*
* 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。
*/
class ShareSendDeclarationTest {
@Test
fun `share never declares on any message of the batch`() {
(0 until ShareRules.MAX_IMAGES).forEach { index ->
assertFalse(
"分享的第 $index 条不许声明(声明就会打断同一批里的上一条)",
ShareRules.declaresSendPreference(fromShare = true, index = index),
)
}
}
@Test
fun `share stays silent even past the image limit`() {
// 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true:
// 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。
(ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index ->
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index))
}
}
@Test
fun `the typed send declares on its first message only`() {
assertTrue(
"手打的第一条仍按「她忙时发消息」偏好投递",
ShareRules.declaresSendPreference(fromShare = false, index = 0),
)
(1 until ShareRules.MAX_IMAGES).forEach { index ->
assertFalse(
"手打的第 $index 条声明就是在打断自己刚起的上一轮",
ShareRules.declaresSendPreference(fromShare = false, index = index),
)
}
}
@Test
fun `a single message batch is the same rule`() {
// 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。
assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0))
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0))
}
}
+4 -1
View File
@@ -474,8 +474,11 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&docum
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。 - `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。 `raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。 - `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。 - `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。 `Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的 挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。 `heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
+60 -7
View File
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep] digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
return "<redacted sha256:{}>".format(digest) return "<redacted sha256:{}>".format(digest)
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
BODY_SUMMARY_STRING_MAX = 200
def summarize_body(raw) -> dict | None:
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
数组长度),只把超长字符串换成 `<N chars>`。
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
也不记大块内容。
"""
if not raw:
return None
try:
parsed = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return {"bytes": len(raw), "note": "not json"}
return _summarize_value(parsed)
def _summarize_value(value):
if isinstance(value, dict):
return {key: _summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return [_summarize_value(item) for item in value]
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
return "<{} chars>".format(len(value))
return value
class JsonlLog: class JsonlLog:
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。""" """一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
@@ -599,11 +630,15 @@ class Store:
with self.lock: with self.lock:
self.downstream_count += 1 self.downstream_count += 1
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict: def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。 """把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx, `Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
也没有理由把它原文写进一个可能被 commit 的文件。 也没有理由把它原文写进一个可能被 commit 的文件。
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
`out/http.jsonl` 里读得出来,不用再去猜。
""" """
recorded_headers = {} recorded_headers = {}
for key, value in headers.items(): for key, value in headers.items():
@@ -623,6 +658,9 @@ class Store:
"query": query, "query": query,
"headers": recorded_headers, "headers": recorded_headers,
} }
summary = summarize_body(body)
if summary is not None:
record["body"] = summary
self.http_log.append(record) self.http_log.append(record)
with self.lock: with self.lock:
self.http_count += 1 self.http_count += 1
@@ -1047,16 +1085,23 @@ class Handler(BaseHTTPRequestHandler):
return parts.path, query return parts.path, query
def _read_body(self): def _read_body(self):
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
if getattr(self, "_body_read", False):
return self._body_cache
self._body_read = True
self._body_cache = b""
length = self.headers.get("Content-Length") length = self.headers.get("Content-Length")
if not length: if not length:
return b"" return self._body_cache
try: try:
size = int(length) size = int(length)
except ValueError: except ValueError:
return b"" return self._body_cache
if size <= 0: if size <= 0:
return b"" return self._body_cache
return self.rfile.read(size) or b"" self._body_cache = self.rfile.read(size) or b""
return self._body_cache
def _parse_json_body(self): def _parse_json_body(self):
raw = self._read_body() raw = self._read_body()
@@ -1103,7 +1148,15 @@ class Handler(BaseHTTPRequestHandler):
def _handle(self, method: str): def _handle(self, method: str):
path, query = self._split() path, query = self._split()
self.store.record_http(self._label(), method, path, query, self.headers) # 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
body = (
self._read_body()
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
else b""
)
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
try: try:
if path == "/ws/chat": if path == "/ws/chat":
self._handle_ws(method, query) self._handle_ws(method, query)