feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
This commit is contained in:
AskaEth committed 2026-10-04 21:50:08 +08:00
1 parent c1fa1e9b9a
commit 902b6eecfd
13 files changed
+779 -21

No files matched your search

+23 -1
View File
@@ -100,7 +100,7 @@ const (
// 形状与 ImageResolver 同族:注入式依赖、装配期按消息调用一次、失败时回合
// 继续。差别只在返回值 —— 图像解析就地改写一条消息,而文档解析把「结论」与
// 「正文」分开交回:清单行、截断标记与「清单恒在」由循环统一渲染(解析器缺席
// 或失败时也必须成立),status/chars/truncated 则经 Result.Documents 回给
// 或失败时也必须成立),status/chars/truncated/reason 则经 Result.Documents 回给
// 客户端(§4.4、§6.1)。
//
// 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即
@@ -108,6 +108,10 @@ const (
// 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型,
// 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver
// 满足 ImageResolver 的方式相同。
//
// 契约(v0.63):status == unreadable 时,**正文就是原因** —— 循环把它原样作为
// `documents[].reason` 回给客户端。因此它必须是「人能读懂、不含路径与内部细节」
// 的短句(design/24 §4.4 已是这个口径);原始错误只进日志。
type DocumentResolver interface {
// Resolve 解析 msg 里的文档部件。error 表示整条消息都解析不了(例如回合
// 已被取消);**单份文档**的失败不是 error,而是 unreadable 结论。
@@ -209,6 +213,7 @@ func documentResolution(part model.Content, refs map[string]history.DocumentRef,
if ref.Status == history.DocumentStatusUnreadable && body == "" {
body = documentUnreadableBody
}
ref.Reason = documentReason(ref.Status, body)
if ref.Status != history.DocumentStatusExtracted {
return ref, body
}
@@ -229,6 +234,23 @@ func documentStatusKnown(status string) bool {
}
}
// documentReason 给出随结论回给客户端的**原因文案**(契约 v0.63 的
// `documents[].reason`):只在「读不到」时给,其余状态一律为空(该键因此从 DTO
// 里省略)。
//
// 它就是注入给模型的那份原因文案 —— 装配期把 unreadable 的正文当作原因用
// (解析器读不到时给的就是诚实原因,见 DocumentResolver 的契约;解析器整条报错
// 时是 documentUnreadableBody 这个兜底)。**同一个字符串两处用**是刻意的:客户端
// 看到的理由与模型看到的理由因此不可能分叉,服务端也从不为客户端另编一句。
//
// 只有文案:不含路径、不含凭据、不含存储名以外的标识 —— 原始错误只进日志。
func documentReason(status, body string) string {
if status != history.DocumentStatusUnreadable {
return ""
}
return body
}
// documentBlock 渲染一份文档的注入块:清单行**恒在**(名、MIME、大小、结论),
// 其后是可注入的正文,或读不到时的诚实原因(design/24 §4.2-3、§4.4)。
func documentBlock(part model.Content, ref history.DocumentRef, body string) string {