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

+108
View File
@@ -0,0 +1,108 @@
package documents
import (
"context"
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是契约 v0.63 的 `documents[].reason` 在**摄取层侧**的端到端回归(客户端
// 登记:`status:"unreadable"` 只有状态没有原因,客户端只能显示通用句,不愿自己
// 编服务端的理由)。它与 sidecar_test.go 的 `TestResolver_EndToEndWithLoop` 同族:
// 真实文件 → 真实 sidecar → 真实 loop 装配期,只是这里断言的是**回给客户端的原因**
// 与**注入模型的原因**是同一个字符串,以及「读不到才给、能读到就没有」这条口径。
// TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim 钉住契约 v0.63 的 ①:
// **扫描件 PDF(无文本层)** 经真实链路(真文件 → 真 sidecar → 真 loop 装配期)
// 后,`Result.Documents` 上带回**逐字**等于注入模型那份的原因文案(含「OCR」),
// 而**文本层 PDF / 纯文本**(extracted)一个 reason 都没有(该键从 DTO 里省略)。
//
// 「逐字一致」是本用例的核心断言:客户端展示的理由与模型看到的理由必须是同一个
// 字符串,服务端不为客户端另编一句。
func TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim(t *testing.T) {
t.Parallel()
layout := newTestLayout(t)
body := "季度报告:营收增长 12%。\n"
textName := layout.writeUpload(t, txtName, []byte(body))
scanName := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}}))
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
ad := &recordingAdapter{}
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1})
msg := model.Message{Role: model.RoleUser, Content: []model.Content{
{Type: model.ContentTypeText, Text: "这两份都看看"},
{Type: model.ContentTypeDocument, DocumentName: textName, DocumentMIME: "text/plain", DocumentSize: int64(len(body))},
{Type: model.ContentTypeDocument, DocumentName: scanName, DocumentMIME: PDFMIME, DocumentSize: 2048},
}}
res, err := l.Run(context.Background(), "sess-doc-reason", []model.Message{msg})
if err != nil {
t.Fatalf("Run: %v", err)
}
byName := map[string]history.DocumentRef{}
for _, ref := range res.Documents {
byName[ref.Name] = ref
}
if got := byName[textName]; got.Status != history.DocumentStatusExtracted || got.Reason != "" {
t.Fatalf("纯文本结论 = %+v, want extracted 且无 reason", got)
}
scan := byName[scanName]
if scan.Status != history.DocumentStatusUnreadable {
t.Fatalf("扫描件结论 = %+v, want unreadable(这份 PDF 没有文本层)", scan)
}
if scan.Reason != reasonPDFNoTextLayer {
t.Fatalf("扫描件 reason = %q, want %q(必须与注入模型的原因逐字一致)", scan.Reason, reasonPDFNoTextLayer)
}
if !strings.Contains(scan.Reason, "OCR") {
t.Fatalf("扫描件 reason = %q, want 含「OCR」", scan.Reason)
}
// 模型可见的那一份:同一句话(清单块之后的诚实原因)。
if len(ad.reqs) == 0 {
t.Fatal("没有记录到模型请求")
}
modelSees := false
for _, m := range ad.reqs[0].Messages {
for _, part := range m.Content {
if part.Type == model.ContentTypeText && strings.Contains(part.Text, reasonPDFNoTextLayer) {
modelSees = true
}
}
}
if !modelSees {
t.Fatal("注入模型的原因文案与回给客户端的 reason 不是同一句")
}
// 反证:原因里没有路径 —— 盘上路径只可能是 `<uploads>/<存储名>`,原始错误若被
// 透出必然带出它;`uploads` 是真实目录段名,存储名本身则是另一路(DTO 的 name)。
for _, leak := range []string{layout.uploads, layout.dataDir, "uploads", scanName} {
if strings.Contains(scan.Reason, leak) {
t.Fatalf("reason = %q 含路径/引用片段 %q,原因文案里不得有路径", scan.Reason, leak)
}
}
// DTO 形状:unreadable 带 reason 键、extracted 不带。
raw, err := json.Marshal(scan)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"reason":`) {
t.Fatalf("扫描件 DTO = %s, want 带 reason 键", raw)
}
textRaw, err := json.Marshal(byName[textName])
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(textRaw), `"reason"`) {
t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", textRaw)
}
}