# ============================================================ # 黄金测试:会话引用(plan.md §4.7 / 矩阵 63 行) # 解析稳定性 / 按时点快照取值 / 来源删除显式失败 / 进入证据链 # ============================================================ from __future__ import annotations from datetime import UTC, datetime import pytest from server.agent_core.audit import write_audit from server.agent_core.session_ref import ( RefResolutionError, parse_refs, resolve_all, resolve_ref, ) FIXED_NOW = datetime(2026, 8, 2, 10, 30, tzinfo=UTC) def _store_data() -> dict: """工作区快照(与 ProjectStore.snapshot 结构一致 + 方案/版本/报告表)。""" return { "projects": [ {"id": "p1", "name": "青岛Q3排产", "scopeLabel": "工厂/车间", "archived": False}, ], "sessions": [ {"id": "sess_a", "projectId": "p1", "title": "比亚迪插单评估", "status": "done"}, {"id": "sess_personal", "projectId": "__personal__", "title": "草稿", "status": "running"}, ], "messages": { "sess_a": [ {"role": "user", "text": "评估比亚迪插单"}, {"role": "agent", "text": "结论:可插单,交期优先"}, ], }, "scenarios": [ {"scenarioId": "scen_1", "strategy": "交期优先", "kpi": {"avgUtilization": 0.92}}, ], "scheduleVersions": [ {"id": "V20260716-003", "name": "V20260716-003", "summary": "基准版本", "planCount": 12}, ], "reports": [ {"reportId": "rep_1", "title": "上周复盘", "markdown": "插单流程需优化"}, ], } def test_parse_refs_stable_kinds_and_dedup(): text = ( "按 @会话:比亚迪插单评估 的结论重排本周计划," "参考 @方案:交期优先-v2 与 @版本:V20260716-003、@报告:上周复盘;" "再提一次 @会话:比亚迪插单评估。" ) refs = parse_refs(text) assert [(r.kind, r.target_id) for r in refs] == [ ("session", "比亚迪插单评估"), ("scenario", "交期优先-v2"), ("version", "V20260716-003"), ("report", "上周复盘"), ] # 稳定:相同输入 -> 相同输出(顺序、去重、ref_id 均确定) again = parse_refs(text) assert [(r.ref_id, r.raw) for r in refs] == [(r.ref_id, r.raw) for r in again] assert refs[0].ref_id == "session:比亚迪插单评估" # 无引用/非引用 @ 不产生误解析 assert parse_refs("直接重排,没有引用") == [] assert parse_refs("联系邮箱 a@b.com 和 @xxx:空") == [] def test_resolve_ref_takes_time_point_snapshot(): sd = _store_data() resolved = resolve_ref(sd, "session", "sess_a", now=FIXED_NOW) assert resolved.kind == "session" assert resolved.snapshot_at == "2026-08-02 10:30" assert resolved.value["session"]["status"] == "done" assert len(resolved.value["messages"]) == 2 # 被引会话后续变化不影响已取快照(按时点取值硬规则) sd["sessions"][0]["status"] = "archived" sd["messages"]["sess_a"].append({"role": "agent", "text": "后续新结论"}) assert resolved.value["session"]["status"] == "done" assert len(resolved.value["messages"]) == 2 # 版本引用同样按时点快照 version = resolve_ref(sd, "version", "V20260716-003", now=FIXED_NOW) assert version.value["planCount"] == 12 sd["scheduleVersions"][0]["planCount"] = 999 assert version.value["planCount"] == 12 def test_resolve_kind_value_shapes(): sd = _store_data() scenario = resolve_ref(sd, "scenario", "scen_1", now=FIXED_NOW) assert scenario.value["strategy"] == "交期优先" assert scenario.value["kpi"]["avgUtilization"] == 0.92 report = resolve_ref(sd, "report", "rep_1", now=FIXED_NOW) assert report.value["title"] == "上周复盘" # 来源信息随解析结果携带(出处可查) assert resolve_ref(sd, "session", "sess_a", now=FIXED_NOW).source["messageCount"] == 2 def test_resolve_ref_explicit_failure_when_source_deleted_or_inaccessible(): sd = _store_data() # 来源删除:目标不在可见会话列表 with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, "session", "sess_deleted", now=FIXED_NOW) assert exc.value.kind == "session" assert "deleted" in exc.value.reason # 无权访问:会话归属项目已归档 sd["projects"][0]["archived"] = True with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, "session", "sess_a", now=FIXED_NOW) assert "no-access" in exc.value.reason sd["projects"][0]["archived"] = False # 各 kind 目标缺失均显式失败,不泛化掩盖 for kind, target in (("scenario", "scen_missing"), ("version", "V0000"), ("report", "rep_missing")): with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, kind, target, now=FIXED_NOW) assert exc.value.kind == kind assert exc.value.target_id == target # @知识 已是合法 kind(矩阵 63 行):未注入知识查找回调必须显式报错(不静默、不编造) with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, "knowledge", "sop-1", now=FIXED_NOW) assert exc.value.kind == "knowledge" assert "not-wired" in exc.value.reason # 真正的未知 kind 仍是参数级拒绝 with pytest.raises(ValueError): resolve_ref(sd, "bogus", "x", now=FIXED_NOW) def test_resolved_refs_enter_evidence_chain_via_write_audit(): sd = _store_data() refs = parse_refs("按 @会话:sess_a 结论重排,以 @版本:V20260716-003 为基准") resolved, evidence_refs = resolve_all(sd, refs, now=FIXED_NOW) assert len(resolved) == 2 assert evidence_refs == ["session:sess_a", "version:V20260716-003"] # 证据链:evidenceRefs 进入审计事件(write_audit 原样落库) world: dict = {"auditEvents": []} counter = iter(range(1, 100)) def next_id(_kind: str) -> int: return next(counter) event = write_audit( world, next_id, actor="planner", category="SESSION", action="session.ref.resolve", target={"type": "session", "id": "sess_a"}, power="P0", rationale={"intent": "引用方案重排", "refCount": len(evidence_refs)}, evidence_refs=evidence_refs, ) assert event["evidenceRefs"] == ["session:sess_a", "version:V20260716-003"] assert world["auditEvents"][-1]["hash"] == event["hash"] # 引用可追溯:kind:target 可直接回查解析结果 assert [r.ref for r in resolved] == event["evidenceRefs"] def test_four_kinds_remain_compatible_with_knowledge_lookup_param(): """新增 knowledge_lookup 参数不影响 4 类既有引用(向后兼容)。""" sd = _store_data() refs = parse_refs( "按 @会话:sess_a 结论,以 @方案:scen_1、@版本:V20260716-003、@报告:rep_1 为输入") resolved, evidence_refs = resolve_all(sd, refs, now=FIXED_NOW, knowledge_lookup=lambda q: None) assert [r.kind for r in resolved] == ["session", "scenario", "version", "report"] assert evidence_refs == [ "session:sess_a", "scenario:scen_1", "version:V20260716-003", "report:rep_1", ] # 不含 @知识 时,不传 knowledge_lookup 与传入等价(无副作用) resolved2, evidence2 = resolve_all(sd, refs, now=FIXED_NOW) assert evidence2 == evidence_refs