"""UI contracts keep task versions, evidence and independent routes intact.""" from __future__ import annotations import asyncio import copy import json from pathlib import Path from unittest.mock import AsyncMock import httpx import pytest from fastapi import FastAPI from backend.prospecting import cards, orchestrator as module, routes, workflows from backend.prospecting.contracts import IntentPlan, KNOWLEDGE_ID, ProspectingBrief async def empty_analysis(brief, sources, *, context_sources=None): return {"decisions": [{"source_id": row["source_id"], "source_type": row["source_type"], "decision": "exclude", "quote": row["content"], "reason": "本地夹具未符合目标"} for row in sources], "leads": [], "analysis_model": "fixture"} @pytest.fixture def workspace(monkeypatch, tmp_path): from backend.prospecting import usage row = {"id": "ui-task", "knowledge_id": KNOWLEDGE_ID, "status": "collecting", "current_step": "brief", "inputs": {**ProspectingBrief(user_notes="先看一轮找客户", discovery_scope="route_pass").model_dump(), "goal": "先看一轮找客户", "_goal_version": 1, "_execution_policy": "conversation-v1", "_needs_interpretation": False}, "artifacts": {"plan": {"missing": ["你经营什么或希望寻找哪类人?"]}, "messages": []}, "pause_reason": "请补充业务背景"} def update(task_id, **fields): assert task_id == row["id"] row.update(copy.deepcopy(fields)) return copy.deepcopy(row) monkeypatch.setattr(module.db, "get_task", lambda task_id: copy.deepcopy(row) if task_id == row["id"] else None) monkeypatch.setattr(module.db, "update_task", update) monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "fixture-placeholder"}) monkeypatch.setattr(module.runner, "close_live_session", AsyncMock()) monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(side_effect=AssertionError("UI route must not browse"))) monkeypatch.setattr(module.runner, "has_live_session", lambda tid: False) monkeypatch.setattr(module.runner, "is_taken_over", lambda tid: False) for target in (module, workflows, routes): monkeypatch.setattr(target, "task_doc_dir", lambda tid: tmp_path / tid) monkeypatch.setattr(cards, "progress_doc_path", lambda tid: tmp_path / tid / "progress.md") monkeypatch.setattr(routes, "progress_doc_path", lambda tid: tmp_path / tid / "progress.md") monkeypatch.setattr(module, "append_progress", lambda *args, **kwargs: None) monkeypatch.setattr(usage, "task_doc_dir", lambda tid: tmp_path / tid) engine = module.ProspectingOrchestrator() monkeypatch.setattr(engine, "_learn", lambda *args: None) monkeypatch.setattr(engine, "_review", lambda tid, artifacts: artifacts) monkeypatch.setattr(routes, "orchestrator", engine) app = FastAPI() app.include_router(routes.router) return row, engine, app, tmp_path def candidate_sources(row, target=2): row["inputs"].update(target_candidates=target, discovery_scope="candidate_count", user_notes="寻找装修客户") row["artifacts"].update(plan={"missing": []}, sources=[ {"source_id": sid, "source_type": "comment", "content": "想找装修队", "author_profile_url": f"https://www.xiaohongshu.com/user/profile/{person * 24}"} for sid, person in [("a1", "a"), ("a2", "a"), ("b", "b")] ]) async def keep_analysis(brief, sources, *, context_sources=None): return {"decisions": [{**source, "decision": "keep", "quote": source["content"], "reason": "本人需求"} for source in sources], "analysis_model": "fixture"} async def test_number_only_reply_uses_same_goal_without_another_interpretation(workspace, monkeypatch): row, engine, _, _ = workspace row["inputs"].update(discovery_scope="candidate_count", target_customer="想找装修队的人", user_notes="寻找装修客户") row["artifacts"]["plan"] = {"missing": []} nlu = AsyncMock(side_effect=AssertionError("number confirmation must not invoke model")) monkeypatch.setattr(module, "interpret_message", nlu) monkeypatch.setattr(engine, "_run", AsyncMock()) result = await engine.message(row["id"], "20") await engine.jobs[row["id"]] await asyncio.sleep(0) assert result["inputs"]["target_candidates"] == 20 assert result["inputs"]["max_notes"] is None assert result["id"] == "ui-task" and result["inputs"]["_goal_version"] == 2 assert cards.clarification(result) is None nlu.assert_not_awaited() async def test_checkpoint_groups_people_and_reuses_analyzed_evidence(workspace, monkeypatch): row, engine, _, _ = workspace candidate_sources(row) row.update(status="running", current_step="search_read") analyze = AsyncMock(side_effect=keep_analysis) monkeypatch.setattr(module, "analyze_sources", analyze) first = await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"])) assert first["code"] == "target_reached" and first["current"] == 2 assert cards.project(row)["goal_progress"]["counts"]["signals"] == 3 await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"])) analyze.assert_awaited_once() async def test_checkpoint_does_not_apply_analysis_after_goal_version_changed(workspace, monkeypatch): row, engine, _, _ = workspace candidate_sources(row) row.update(status="running", current_step="search_read") async def revise(brief, sources, *, context_sources=None): row["inputs"]["_goal_version"] = 2 row["status"] = "collecting" return await keep_analysis(brief, sources, context_sources=context_sources) monkeypatch.setattr(module, "analyze_sources", revise) assert await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"])) is None assert not row["artifacts"].get("leads") and row["status"] == "collecting" async def test_saved_review_reuses_identical_input_and_force_reanalyzes(workspace, monkeypatch): row, engine, _, _ = workspace candidate_sources(row) row.update(status="blocked", current_step="stage_results") analyze = AsyncMock(side_effect=keep_analysis) monkeypatch.setattr(module, "analyze_sources", analyze) for force in [False, False, True]: await engine.review_saved(row["id"], force=force) await engine.jobs[row["id"]] await asyncio.sleep(0) assert analyze.await_count == 2 assert row["status"] == "completed" module.runner.run_browser_step.assert_not_awaited() async def test_successful_browser_cannot_complete_with_two_signals_from_one_person(workspace, monkeypatch): row, engine, _, _ = workspace candidate_sources(row) row["artifacts"]["sources"].pop() row.update(status="running", current_step="search_read", browser_profile_path="fixture") monkeypatch.setattr(module.db, "list_learned", lambda *args: []) monkeypatch.setattr(module.db, "record_trials", lambda *args: None) monkeypatch.setattr(module, "write_summary", lambda *args: None) monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=keep_analysis)) monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(return_value={"verdict": "success", "stop_reason": "agent_done", "history": {"steps_this_run": 1}, "artifacts": copy.deepcopy(row["artifacts"])})) await engine._run(row["id"], engine._brief(row), "") assert row["status"] == "blocked" and row["artifacts"]["stop_reason"] == "target_shortfall" assert cards.project(row)["goal_progress"]["current"] == 1 def test_revised_goal_preserves_old_evidence_without_counting_toward_new_target(workspace): row, _, _, _ = workspace candidate_sources(row, target=1) row["inputs"]["_goal_version"] = 2 row["artifacts"].update(analysis_goal_version=1, leads=[{**row["artifacts"]["sources"][0], "decision": "keep"}]) progress = cards.project(row)["goal_progress"] assert not progress["assessment_current"] and not progress["reached"] assert progress["current"] == 0 and progress["remaining"] == 1 assert progress["counts"]["candidates"] == 1 and progress["groups"] async def test_achieved_target_resume_does_not_open_browser_or_reanalyze(workspace, monkeypatch): from backend.prospecting.execution import analyze_evidence row, engine, _, _ = workspace candidate_sources(row) row["artifacts"].update(await analyze_evidence(row, engine._brief(row), keep_analysis)) run = AsyncMock(side_effect=AssertionError("achieved target must not restart")) monkeypatch.setattr(engine, "_run", run) result = await engine.resume(row["id"]) assert result["status"] == "completed" and result["artifacts"]["stop_reason"] == "target_reached" run.assert_not_awaited() module.runner.run_browser_step.assert_not_awaited() async def test_csv_groups_people_and_preserves_all_source_quotes_links_and_corrections(workspace): import csv from backend.prospecting.execution import analyze_evidence row, engine, _, _ = workspace candidate_sources(row) for index, source in enumerate(row["artifacts"]["sources"]): source.update(content=f"本人需求{index}", source_url=f"https://www.xiaohongshu.com/explore/fixture{index}") row["artifacts"]["corrections"] = [{"source_id": "a2", "text": "还需确认预算"}] row["artifacts"].update(await analyze_evidence(row, engine._brief(row), keep_analysis)) exported = workflows.export_leads(row) with Path(exported["path"]).open(encoding="utf-8-sig", newline="") as handle: rows = list(csv.DictReader(handle)) assert exported["count"] == exported["candidate_count"] == len(rows) == 2 assert "本人需求0" in rows[0]["需求原话"] and "本人需求1" in rows[0]["需求原话"] assert "fixture0" in rows[0]["来源链接"] and "fixture1" in rows[0]["来源链接"] assert rows[0]["用户纠正"] == "还需确认预算" and rows[0]["小红书号"] == "" def add_sources(row): row["artifacts"].update({ "sources": [{"source_id": "n", "source_type": "note", "content": "装修准备", "source_url": "https://www.xiaohongshu.com/explore/a"}, {"source_id": "c", "source_type": "comment", "content": "想找装修队", "author": "本人", "source_url": "https://www.xiaohongshu.com/explore/a"}, {"source_id": "r", "source_type": "reply", "content": "求设计师", "author": "回复者", "source_url": "https://www.xiaohongshu.com/explore/a"}], "leads": [{"source_id": "c", "quote": "想找装修队", "reason": "本人询问", "status": "pending"}], "profiles": [], "decisions": [{"source_id": "c", "decision": "keep"}]}) def test_unknown_question_is_context_not_mandatory_product(workspace): row, _, _, _ = workspace card = cards.clarification(row) assert len(card["fields"]) == 1 assert card["fields"][0]["key"] == "context" assert card["fields"][0]["prefill"] == "" assert card == cards.clarification(copy.deepcopy(row)) assert row["inputs"]["max_notes"] is None and row["inputs"]["max_comments_per_note"] is None async def test_clarification_continues_same_task_through_chat_and_rejects_replay(workspace, monkeypatch): row, engine, _, _ = workspace card = cards.clarification(row) field_id = card["fields"][0]["id"] nlu = AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="整理已有名单")) monkeypatch.setattr(module, "interpret_message", nlu) result = await engine.clarify(row["id"], card["id"], 1, {field_id: "先整理已有名单"}) assert result["id"] == "ui-task" and result["inputs"]["_goal_version"] == 2 assert result["current_step"] == "export_ready" assert "先整理已有名单" in nlu.await_args.args[0] assert result["artifacts"]["clarification_history"][0]["status"] == "answered" with pytest.raises(ValueError, match="已更新或已回答"): await engine.clarify(row["id"], card["id"], 1, {field_id: "重复"}) nlu.assert_awaited_once() module.runner.run_browser_step.assert_not_awaited() async def test_clarification_rejects_stale_or_extra_fields_without_consuming(workspace): row, engine, _, _ = workspace card = cards.clarification(row) answers = {card["fields"][0]["id"]: "装修"} for version, values in ((2, answers), (1, {**answers, "_steps_used": "0"}), (1, {next(iter(answers)): " "})): with pytest.raises(ValueError): await engine.clarify(row["id"], card["id"], version, values) assert not row["artifacts"].get("clarification_submissions") assert row["inputs"]["_goal_version"] == 1 async def test_saved_form_reply_can_retry_interpretation_after_failure(workspace, monkeypatch): row, engine, _, _ = workspace card = cards.clarification(row) monkeypatch.setattr(module, "interpret_message", AsyncMock(side_effect=ValueError("意图服务暂时不可用"))) await engine.clarify(row["id"], card["id"], 1, {card["fields"][0]["id"]: "只导出已有名单"}) assert row["status"] == "blocked" and row["inputs"]["_needs_interpretation"] is True assert "只导出已有名单" in row["inputs"]["goal"] monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="导出已有名单"))) result = await engine.resume(row["id"]) assert result["status"] == "completed" and result["id"] == "ui-task" assert len(result["artifacts"]["clarification_submissions"]) == 1 async def test_chat_reply_preserves_old_card_in_history(workspace, monkeypatch): row, engine, _, _ = workspace old = cards.clarification(row) monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="导出已有名单"))) await engine.message(row["id"], "整理之前已有名单") history = row["artifacts"]["clarification_history"][0] assert history["id"] == old["id"] and history["status"] == "responded_in_chat" assert cards.clarification(row) is None def test_projection_counts_actual_replies_and_real_progress_events(workspace): row, _, _, tmp_path = workspace add_sources(row) row.update(status="running", current_step="search_read") directory = tmp_path / row["id"] directory.mkdir() (directory / "progress.md").write_text("### ℹ️ [2026-10-29 11:30:00] 步骤 12\n\n- **状态**:running\n- **详情**:动作:scroll\n\n", encoding="utf-8") ui = cards.project(row) assert ui["phase"]["counts"] == {"notes": 1, "comments": 1, "replies": 1, "profiles": 0, "leads": 1} assert ui["phase"]["events"][0]["title"] == "步骤 12" assert "percent" not in json.dumps(ui) assert ui["clarification"] is None def test_export_manifest_survives_old_task_artifact_write_and_marks_versions(workspace): row, _, _, _ = workspace add_sources(row) row.update(status="blocked", current_step="stage_results") old_artifacts = copy.deepcopy(row["artifacts"]) first = workflows.export_leads(copy.deepcopy(row)) second = workflows.export_leads(copy.deepcopy(row)) assert first["id"] != second["id"] and first["path"] != second["path"] assert len(row["artifacts"]["exports"]) == 2 row["artifacts"] = old_artifacts assert len(cards.project(row)["delivery"]["exports"]) == 2 row["artifacts"]["profiles"] = [{"source_id": "c", "xiaohongshu_id": "newly-verified"}] assert all(not item["is_current"] for item in workflows.list_exports(row)) path, metadata = workflows.export_path(row, first["id"]) assert path.is_file() and metadata["result_version"] == first["result_version"] def test_correction_updates_result_version_and_missing_export_is_not_openable(workspace): row, _, _, _ = workspace add_sources(row) entry = workflows.export_leads(row) row["artifacts"]["corrections"] = [{"source_id": "c", "text": "还需要核实是否本人找服务"}] shown = workflows.list_exports(row)[0] assert not shown["is_current"] and shown["available"] Path(entry["path"]).unlink() shown = cards.project(row)["delivery"]["exports"][0] assert not shown["available"] and shown["path"] == "" def test_export_download_rejects_foreign_id_and_manifest_path_escape(workspace): row, _, _, tmp_path = workspace add_sources(row) entry = workflows.export_leads(row) with pytest.raises(ValueError): workflows.export_path(row, "a" * 32) manifest = tmp_path / row["id"] / "exports" / "manifest.json" data = json.loads(manifest.read_text()) data[0]["filename"] = "../private.csv" manifest.write_text(json.dumps(data)) with pytest.raises(ValueError, match="位置无法核对"): workflows.export_path(row, entry["id"]) async def test_independent_review_never_browses_or_clears_account_pause(workspace, monkeypatch): row, engine, _, _ = workspace add_sources(row) row.update(status="blocked", current_step="outreach_results") pause = {"message": "发送结果未知,账号后续私信已暂停", "daily_limit": False} ledger = [{"id": "action", "status": "unknown"}] row["artifacts"].update(outreach_pause=pause, action_ledger=ledger, stop_reason={"code": "message_failed"}, stop_detail=pause["message"]) monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis)) await engine.review_saved(row["id"]) with pytest.raises(ValueError, match="当前阶段"): await engine.review_saved(row["id"]) await engine.jobs[row["id"]] await asyncio.sleep(0) assert row["status"] == "blocked" assert row["artifacts"]["outreach_pause"] == pause and row["artifacts"]["action_ledger"] == ledger assert row["inputs"]["_goal_version"] == 1 module.runner.run_browser_step.assert_not_awaited() async def test_saved_review_restores_checkpoint_and_accepts_normal_dict_reason(workspace, monkeypatch): row, engine, _, tmp_path = workspace row.update(status="completed", current_step="results") add_sources(row) saved = copy.deepcopy(row["artifacts"]["sources"]) row["artifacts"]["sources"] = [] row["artifacts"]["stop_reason"] = {"code": "goal_completed"} directory = tmp_path / row["id"] directory.mkdir() (directory / "evidence.json").write_text(json.dumps({"sources": saved}), encoding="utf-8") analyze = AsyncMock(side_effect=empty_analysis) monkeypatch.setattr(module, "analyze_sources", analyze) await engine.review_saved(row["id"]) await engine.jobs[row["id"]] await asyncio.sleep(0) assert analyze.await_args.args[1] == saved assert row["status"] == "completed" and row["artifacts"]["sources"] == saved module.runner.run_browser_step.assert_not_awaited() @pytest.mark.parametrize("stop,verdict,history_success", [ ("agent_done", "failed", False), ("review_completed", "failed", False), ("agent_done", "success", False), ]) async def test_review_cannot_complete_an_unsuccessful_collection(workspace, monkeypatch, stop, verdict, history_success): row, engine, _, _ = workspace add_sources(row) row.update(status="completed", current_step="results") row["artifacts"].update(stop_reason=stop, browser_verdict=verdict, browser_history={"is_done": True, "is_successful": history_success}) monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis)) await engine.review_saved(row["id"]) await engine.jobs[row["id"]] await asyncio.sleep(0) assert row["status"] == "blocked" and row["current_step"] == "stage_results" assert row["artifacts"]["stop_reason"] == "agent_reported_failure" assert "原网页采集仍有未完成范围" in row["artifacts"]["stop_detail"] module.runner.run_browser_step.assert_not_awaited() async def test_read_projection_corrects_old_review_success_and_actual_search_coverage_without_writing(workspace): row, _, app, _ = workspace add_sources(row) row.update(status="completed", current_step="results") row["inputs"].update(intent="discover", keywords=["杭州跟妆", "杭州新娘跟妆", "求跟妆"]) row["artifacts"].update(stop_reason="review_completed", summary="已复核7条保存资料", browser_verdict="failed", browser_history={"is_done": True, "is_successful": False, "urls": [ "https://www.xiaohongshu.com/search_result_ai?keyword=%25E6%259D%25AD%25E5%25B7%259E%25E8%25B7%259F%25E5%25A6%2586", "https://www.xiaohongshu.com/search_result_ai?keyword=杭州新娘跟妆"]}) original = copy.deepcopy(row) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client: result = (await client.get("/tasks/ui-task")).json() assert row == original assert result["status"] == "blocked" and result["current_step"] == "stage_results" assert result["ui"]["phase"]["coverage"] == {"observed_keywords": ["杭州跟妆", "杭州新娘跟妆"], "pending_keywords": ["求跟妆"]} assert result["artifacts"]["summary"].startswith("阶段结果:") assert result["artifacts"]["stop_reason"] == "agent_reported_failure" def test_read_projection_does_not_downgrade_explicit_export_completion(workspace): row, _, _, _ = workspace row.update(status="completed", current_step="export_ready") row["inputs"]["intent"] = "export" row["artifacts"].update(stop_reason="review_completed", browser_verdict="failed") assert routes.observed_task(row)["status"] == "completed" async def test_failed_browser_release_does_not_leave_unowned_running_review(workspace, monkeypatch): row, engine, _, _ = workspace add_sources(row) row["status"] = "blocked" monkeypatch.setattr(module.runner, "close_live_session", AsyncMock(side_effect=RuntimeError("fixture"))) with pytest.raises(ValueError, match="复核没有启动"): await engine.review_saved(row["id"]) assert row["status"] == "blocked" and not engine.jobs async def test_routes_reject_stale_actions_and_running_exports_and_serve_task_file(workspace): row, _, app, _ = workspace add_sources(row) row.update(status="blocked", current_step="stage_results") async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client: stale = await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 2, "result_version": workflows.result_version(row)}) assert stale.status_code == 409 fresh = await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 1, "result_version": workflows.result_version(row)}) assert fresh.status_code == 200 entry = fresh.json()["ui"]["delivery"]["exports"][0] download = await client.get(entry["download_url"]) assert download.status_code == 200 and "text/csv" in download.headers.get("content-type", "") assert "小红书号" in download.text assert (await client.get("/tasks/foreign/exports/" + entry["id"])).status_code == 404 row["status"] = "running" assert (await client.post("/tasks/ui-task/export")).status_code == 409 assert (await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 1, "result_version": workflows.result_version(row)})).status_code == 409 async def test_form_http_contract_returns_task_and_duplicate_is_conflict(workspace, monkeypatch): row, _, app, _ = workspace nlu = AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="整理名单")) monkeypatch.setattr(module, "interpret_message", nlu) card = cards.clarification(row) body = {"card_id": card["id"], "goal_version": 1, "answers": {card["fields"][0]["id"]: "导出已有名单"}} async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client: result = await client.post("/tasks/ui-task/clarification", json=body) assert result.status_code == 200 and result.json()["id"] == row["id"] assert result.json()["ui"]["clarification"] is None assert (await client.post("/tasks/ui-task/clarification", json=body)).status_code == 409 nlu.assert_awaited_once() @pytest.mark.parametrize("previous_reason", ["agent_done", "platform_limited"]) async def test_review_error_is_current_while_original_stop_is_preserved(workspace, monkeypatch, previous_reason): row, engine, _, _ = workspace add_sources(row) row.update(status="blocked", current_step="stage_results") row["artifacts"].update(stop_reason=previous_reason, stop_detail="之前的结束说明") monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=ValueError("本次资料分析连接失败"))) await engine.review_saved(row["id"]) await engine.jobs[row["id"]] await asyncio.sleep(0) ui = cards.project(row) assert ui["phase"]["detail"] == "本次资料分析连接失败" assert ui["recovery"]["reason"] == "review_error" assert ui["recovery"]["underlying_reason"] == previous_reason assert row["artifacts"]["stop_reason"] == previous_reason assert row["artifacts"]["stop_detail"] == "之前的结束说明" assert len(row["artifacts"]["leads"]) == 1 monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis)) await engine.resume(row["id"]) await engine.jobs[row["id"]] await asyncio.sleep(0) assert "review_error" not in row["artifacts"] module.runner.run_browser_step.assert_not_awaited() async def test_ui_version_and_actions_use_same_richer_checkpoint(workspace, monkeypatch): row, engine, app, tmp_path = workspace add_sources(row) row.update(status="blocked", current_step="stage_results") row["artifacts"]["sources"] = [] checkpoint = [{"source_id": "c", "source_type": "comment", "content": "想找装修队", "author": "检查点昵称", "source_url": "https://www.xiaohongshu.com/explore/a", "observed_at": "2026-10-29T10:00:00+08:00"}] directory = tmp_path / row["id"] directory.mkdir() (directory / "evidence.json").write_text(json.dumps({"sources": checkpoint}), encoding="utf-8") analyze = AsyncMock(side_effect=empty_analysis) monkeypatch.setattr(module, "analyze_sources", analyze) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client: shown = (await client.get("/tasks/ui-task")).json() assert shown["ui"]["delivery"]["source_count"] == 1 payload = {key: shown["ui"][key] for key in ("goal_version", "result_version")} exported = await client.post("/tasks/ui-task/alternatives/export", json=payload) assert exported.status_code == 200 entry = exported.json()["ui"]["delivery"]["exports"][0] assert entry["result_version"] == payload["result_version"] and entry["is_current"] assert "检查点昵称" in Path(entry["path"]).read_text(encoding="utf-8-sig") reviewed = await client.post("/tasks/ui-task/alternatives/review", json=payload) assert reviewed.status_code == 200 await engine.jobs[row["id"]] await asyncio.sleep(0) assert analyze.await_args.args[1] == checkpoint module.runner.run_browser_step.assert_not_awaited() def test_older_checkpoint_does_not_replace_newer_database_evidence(workspace): row, engine, _, tmp_path = workspace row["artifacts"]["sources"] = [{"source_id": "same", "source_type": "comment", "author": "最新核对", "observed_at": "2026-10-29T11:00:00+08:00"}, {"source_id": "db-only", "source_type": "reply"}] row["artifacts"]["profiles"] = [{"source_id": "same", "xiaohongshu_id": "最新账号", "observed_at": "2026-10-29T11:00:00+08:00"}] directory = tmp_path / row["id"] directory.mkdir() (directory / "evidence.json").write_text(json.dumps({"sources": [ {"source_id": "same", "source_type": "comment", "author": "旧截图", "observed_at": "2026-10-29T10:00:00+08:00"}, {"source_id": "checkpoint-only", "source_type": "note"}], "profiles": [ {"source_id": "same", "xiaohongshu_id": "旧账号", "observed_at": "2026-10-29T10:00:00+08:00"}]}), encoding="utf-8") restored = engine._recover_evidence(row["id"], row["artifacts"]) assert {item["source_id"] for item in restored["sources"]} == {"same", "db-only", "checkpoint-only"} assert next(item for item in restored["sources"] if item["source_id"] == "same")["author"] == "最新核对" assert restored["profiles"][0]["xiaohongshu_id"] == "最新账号"