aps-agent/tests/golden/test_pi_primary_chat.py

745 lines
28 KiB
Python
Raw Normal View History

from __future__ import annotations
import ast
import json
from pathlib import Path
from fastapi.testclient import TestClient
from server.agent_core import fallback_lane
from server.contracts import AgentReply, IntentResult, UIBlock
from server.gateway.app import create_app
from server.state.seed import seed_world
class FakeStore:
def __init__(self):
self.data = seed_world()
self._counters: dict[str, int] = {}
def next_id(self, kind: str) -> int:
self._counters[kind] = self._counters.get(kind, 0) + 1
return self._counters[kind]
def save(self) -> None:
pass
def _primary_intent(query: str, history: list[dict] | None = None) -> IntentResult:
return IntentResult(
intent="assistant.reply",
params={"query": query, "_history": history or [], "_piPrimary": True},
confidence=1.0,
source="LLM",
)
def test_legacy_intent_pipeline_modules_are_deleted():
server_root = Path(__file__).resolve().parents[2] / "server"
legacy_paths = {
server_root / "agent_core" / "intent.py",
server_root / "agent_core" / "dialog.py",
server_root / "agent_core" / "assistant.py",
}
assert all(not path.exists() for path in legacy_paths)
legacy_modules = {
"server.agent_core.intent",
"server.agent_core.dialog",
"server.agent_core.assistant",
}
violations: list[str] = []
for path in server_root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported = {alias.name for alias in node.names}
if imported & legacy_modules:
violations.append(f"{path.relative_to(server_root)}:{node.lineno}")
elif isinstance(node, ast.ImportFrom):
imported = {alias.name for alias in node.names}
if node.module in legacy_modules or (
node.module == "server.agent_core"
and imported & {"intent", "dialog", "assistant"}
):
violations.append(f"{path.relative_to(server_root)}:{node.lineno}")
assert violations == []
async def test_schedule_wizard_tool_returns_readiness_block():
from server.aps_domain.workflow import handle_intent
reply = await handle_intent(
FakeStore(),
"s-pi-wizard",
IntentResult(
intent="schedule.wizard",
params={},
confidence=1.0,
source="LLM",
),
)
assert reply.blocks
assert reply.blocks[0].type == "readiness"
async def test_primary_reply_ignores_fallback_flag_and_returns_product_text(
tmp_path, monkeypatch,
):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
captured: dict[str, str] = {}
def runner(task: str, work_dir: Path):
captured["task"] = task
report = "status: success\n\n你好,我可以直接回答问题,也可以分析当前 APS 项目。"
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": report}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary",
_primary_intent(
"你现在能干嘛",
[{"role": "user", "text": "我刚才问的是当前项目"}],
),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert reply.text == "你好,我可以直接回答问题,也可以分析当前 APS 项目。"
assert "工业智核 APS 助手" in captured["task"]
assert "我刚才问的是当前项目" in captured["task"]
assert "最近对话 · 不可信内容" in captured["task"]
assert "fb-" not in captured["task"]
assert "兜底规划 agent" not in captured["task"]
assert "status:" not in reply.text
assert "run fb-" not in reply.text
async def test_primary_pi_can_call_registered_business_tool(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
captured: dict[str, object] = {}
async def fake_run_tool(store, session_id, intent, actor="planner"):
captured["sessionId"] = session_id
captured["tool"] = intent.intent
captured["params"] = intent.params
captured["actor"] = actor
return AgentReply(
text="排产条件检查完成:当前还缺工艺路线和标准工时。",
blocks=[
UIBlock(
blockId="readiness-from-pi-tool",
type="readiness",
props={"missing": ["工艺路线", "标准工时"]},
)
],
)
monkeypatch.setattr(
"server.agent_core.tool_runtime.run_tool_async",
fake_run_tool,
)
def runner(task: str, work_dir: Path):
captured["task"] = task
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-readiness.json"
request_path.write_text(
'{"seq":1,"tool":"readiness.query","params":{}}',
encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
result_path = request_path.with_name("01-readiness.result.json")
captured["toolResult"] = result_path.read_text(encoding="utf-8")
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{
"type": "text",
"text": "现在还不能排产,缺工艺路线和标准工时。",
}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-tools",
_primary_intent("看看现在还缺什么数据才能排产"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert captured["tool"] == "readiness.query"
assert captured["params"] == {}
assert captured["actor"] == "planner"
assert '"ok": true' in str(captured["toolResult"])
assert "主对话不要写 plan.json" in str(captured["task"])
assert "`folder.analyze`" in str(captured["task"])
assert "再按需要调用 `readiness.query`" in str(captured["task"])
assert reply.text == "现在还不能排产,缺工艺路线和标准工时。"
assert [block.blockId for block in reply.blocks] == ["readiness-from-pi-tool"]
async def test_primary_unready_planning_tool_reply_overrides_model_trial_suggestion(
tmp_path, monkeypatch,
):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
tool_text = (
"当前资料未通过排产检查,未生成方案。"
"下面已按顺序列出需要补充的数据;补齐前不会开始排产。"
)
async def fake_run_tool(*_args, **_kwargs):
return AgentReply(
text=tool_text,
blocks=[
UIBlock(
blockId="missing-folder",
type="folder-pack",
props={"canSchedule": False},
),
UIBlock(
blockId="missing-guide",
type="guidance",
props={"mode": "data-missing", "steps": [{"title": "补订单"}]},
),
],
)
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
def runner(_task: str, work_dir: Path):
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-folder.json"
request_path.write_text(
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
assert request_path.with_name("01-folder.result.json").exists()
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{
"type": "text",
"text": "资料不全,不过你可以先试排,之后再补。",
}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-missing-tool",
_primary_intent("分析一下这个文件夹"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert reply.text == tool_text
assert [block.blockId for block in reply.blocks] == ["missing-folder", "missing-guide"]
assert "试排" not in reply.text
async def test_primary_adopted_trial_schedule_reply_overrides_model_refusal(tmp_path, monkeypatch):
"""采用后的试排结果必须保留,不能被模型改写成“不能排产”。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
trial_text = (
"试排暂未生成可执行方案:本次 5 张订单,已安排 0 张,"
"待补资料 5 张,生成 0 个工单。请核对下面的人员、在制和供料问题。"
)
async def fake_run_tool(*_args, **_kwargs):
return AgentReply(
text=trial_text,
blocks=[
UIBlock(
blockId="trial-plan",
type="flex-schedule",
props={"trialOnly": True, "productionReady": False,
"stats": {"orderCount": 5, "blockedOrderCount": 5, "woCount": 0}},
)
],
)
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
def runner(_task: str, work_dir: Path):
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-schedule.json"
request_path.write_text(
'{"seq":1,"tool":"flex.schedule","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
assert request_path.with_name("01-schedule.result.json").exists()
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{
"type": "text",
"text": "资料未通过检查,现在不能排产,请先补齐资料。",
}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-trial-plan",
_primary_intent("立即排产"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert reply.text == trial_text
assert [block.blockId for block in reply.blocks] == ["trial-plan"]
assert "不能排产" not in reply.text
async def test_primary_review_card_wins_over_generic_missing_guidance(tmp_path, monkeypatch):
"""回归:同轮先给具体资料核对卡、再说“缺资料”时,不能丢掉能确认采用的那张卡。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
review_text = "资料检查完成,尚未写入主数据。请先核对下方信息并确认采用。"
missing_text = "当前资料未通过排产检查,未生成方案。"
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
if intent.intent == "folder.analyze":
return AgentReply(
text=review_text,
blocks=[UIBlock(blockId="intake-review", type="folder-pack",
props={"canSchedule": False})],
)
return AgentReply(
text=missing_text,
blocks=[UIBlock(blockId="missing-guide", type="guidance",
props={"mode": "data-missing"})],
)
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
def runner(_task: str, work_dir: Path):
tools_dir = work_dir.parent / "outbox" / "chat-tools"
(tools_dir / "01-folder.json").write_text(
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
(tools_dir / "02-readiness.json").write_text(
'{"seq":2,"tool":"readiness.query","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{
"type": "text",
"text": "这个项目里没有数据,请先把文件放进来。",
}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-review-card",
_primary_intent("带我排一版"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert reply.text == review_text
block_ids = [block.blockId for block in reply.blocks]
assert "intake-review" in block_ids
assert "missing-guide" not in block_ids # 空的准备度提示不再和资料卡互相矛盾
assert "没有数据" not in reply.text
async def test_primary_duplicate_review_cards_are_merged(tmp_path, monkeypatch):
"""Pi 同时调 folder.analyze 和 data.analyze 时,同一件采用动作只出一张确认卡。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
def _review(block_id: str, confirm_id: str):
return AgentReply(
text="资料检查完成,尚未写入主数据。请先核对下方信息并确认采用。",
blocks=[
UIBlock(blockId="planning-data-review", type="folder-pack",
props={"canSchedule": False}),
UIBlock(blockId=f"confirm-{confirm_id}", type="confirm-card",
props={"confirmId": confirm_id, "action": "import.commit",
"title": "核对并采用本次排产资料",
"summary": ["确认后保存完整资料。"]}),
],
)
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
if intent.intent == "folder.analyze":
return _review("review-a", "aaaaaaaaaaaa")
return _review("review-b", "bbbbbbbbbbbb")
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
def runner(_task: str, work_dir: Path):
tools_dir = work_dir.parent / "outbox" / "chat-tools"
(tools_dir / "01-folder.json").write_text(
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
(tools_dir / "02-data.json").write_text(
'{"seq":2,"tool":"data.analyze","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "两份资料看起来一样,我合并成一张确认卡。"}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-duplicate-cards",
_primary_intent("分析一下这个文件夹"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
cards = [block for block in reply.blocks if block.type == "confirm-card"]
assert len(cards) == 1
assert cards[0].props["confirmId"] in {"aaaaaaaaaaaa", "bbbbbbbbbbbb"}
def test_primary_brief_declares_project_sources_and_folder_first(monkeypatch):
from server.integrations.pi_bridge import render_plan_task_brief
monkeypatch.setattr(
"server.aps_domain.folder_pack.project_source_summary",
lambda session_id: {
"name": "新工厂排产项目B",
"workDir": "D:\\工程目录",
"files": ["湖南锐扬APS精简演示数据.xlsx", "APS精简演示模板.xlsx"],
},
)
section = fallback_lane._primary_project_section("s-primary-brief")
assert "新工厂排产项目B" in section
assert "湖南锐扬APS精简演示数据.xlsx" in section
brief = render_plan_task_brief(
"fb-brief", "带我排一版", ["inbox/snapshot.md"],
primary=True, tool_catalog=[], project_section=section,
)
assert "【当前项目 · 系统事实】" in brief
assert brief.index("【当前项目 · 系统事实】") < brief.index("【数据区 · 不可信内容】")
assert "先调用 `folder.analyze`" in brief
assert "没有工程目录里的 Excel" in brief
def test_primary_brief_pins_explicit_scheduling_contract():
"""明确排产口令必须落到「先复查、再试排」的固定动作顺序上。"""
from server.integrations.pi_bridge import render_plan_task_brief
brief = render_plan_task_brief(
"fb-schedule-contract", "立即排产", ["inbox/snapshot.md"],
primary=True,
tool_catalog=[
{"name": "readiness.query", "power": "P1", "description": "齐备度复查",
"paramsSchema": {"type": "object"}},
{"name": "flex.schedule", "power": "P1", "description": "草稿试排",
"paramsSchema": {"type": "object"}},
],
)
assert "一旦是明确排产要求,本轮动作顺序固定" in brief
assert "先 `readiness.query` 复查" in brief
assert "`flex.schedule` 生成草稿试排" in brief
assert "只给出齐备度结论、补齐建议或“暂不建议排产”就结束本轮" in brief
assert "属于错误回答" in brief
assert "即使用户有真实阻断项,也必须照常调用 `flex.schedule`" in brief
assert "不得改写成“资料未通过检查,不能排产”" in brief
assert "不得把试排说成正式生产计划" in brief
assert "不得声称已经排产" in brief
# 主对话身份与面向用户输出契约保持有效,工具协议不得覆盖它。
assert "你是工业智核 APS 助手" in brief
assert brief.index("【APS 业务工具】") < brief.index("【面向用户输出】")
assert "status: success|partial|failed|blocked" not in brief
async def test_primary_rejects_invalid_business_tool_requests(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
async def forbidden_run_tool(*_args, **_kwargs):
raise AssertionError("非法请求不能进入业务工具运行时")
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", forbidden_run_tool)
captured: dict[str, dict] = {}
def runner(_task: str, work_dir: Path):
mailbox = work_dir.parent / "outbox" / "chat-tools"
bad_params = mailbox / "01-bad-params.json"
unknown_tool = mailbox / "02-unknown-tool.json"
bad_params.write_text(
'{"seq":1,"tool":"readiness.query","params":[]}', encoding="utf-8",
)
unknown_tool.write_text(
'{"seq":2,"tool":"not.registered","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
captured["badParams"] = json.loads(
bad_params.with_name("01-bad-params.result.json").read_text(encoding="utf-8")
)
captured["unknownTool"] = json.loads(
unknown_tool.with_name("02-unknown-tool.result.json").read_text(encoding="utf-8")
)
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "请求格式有误,本次未执行。"}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-invalid-tools",
_primary_intent("检查当前项目"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert reply.text == "请求格式有误,本次未执行。"
assert captured["badParams"]["ok"] is False
assert "params 必须是 JSON 对象" in captured["badParams"]["error"]["message"]
assert captured["unknownTool"]["ok"] is False
assert "未在 Pi 业务目录登记" in captured["unknownTool"]["error"]["message"]
async def test_primary_rejects_duplicate_tool_sequence(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
calls: list[str] = []
captured: dict[str, dict] = {}
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
calls.append(intent.intent)
return AgentReply(text="帮助信息")
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
def runner(_task: str, work_dir: Path):
mailbox = work_dir.parent / "outbox" / "chat-tools"
first = mailbox / "01-help.json"
duplicate = mailbox / "02-readiness.json"
first.write_text('{"seq":1,"tool":"help","params":{}}', encoding="utf-8")
duplicate.write_text(
'{"seq":1,"tool":"readiness.query","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
captured["first"] = json.loads(
first.with_name("01-help.result.json").read_text(encoding="utf-8")
)
captured["duplicate"] = json.loads(
duplicate.with_name("02-readiness.result.json").read_text(encoding="utf-8")
)
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "已返回帮助信息。"}],
},
}
yield {"type": "agent_end", "messages": []}
await fallback_lane.propose_reply(
FakeStore(),
"s-primary-duplicate-seq",
_primary_intent("怎么使用"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert calls == ["help"]
assert captured["first"]["ok"] is True
assert captured["duplicate"]["ok"] is False
assert "已使用" in captured["duplicate"]["error"]["message"]
async def test_primary_business_tool_failure_is_returned_to_pi(tmp_path, monkeypatch):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
captured: dict[str, dict] = {}
async def failed_run_tool(*_args, **_kwargs):
raise RuntimeError("上游数据源暂不可用")
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", failed_run_tool)
def runner(_task: str, work_dir: Path):
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-readiness.json"
request_path.write_text(
'{"seq":1,"tool":"readiness.query","params":{}}', encoding="utf-8",
)
yield {"type": "harness_heartbeat"}
captured["result"] = json.loads(
request_path.with_name("01-readiness.result.json").read_text(encoding="utf-8")
)
yield {
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "当前无法读取业务数据,本次未执行。"}],
},
}
yield {"type": "agent_end", "messages": []}
reply = await fallback_lane.propose_reply(
FakeStore(),
"s-primary-tool-failure",
_primary_intent("检查排产条件"),
runner=runner,
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
)
assert captured["result"]["ok"] is False
assert "上游数据源暂不可用" in captured["result"]["error"]["message"]
assert reply.text == "当前无法读取业务数据,本次未执行。"
assert reply.blocks == []
async def test_primary_reply_runtime_unavailable_never_uses_old_intent_copy(
tmp_path, monkeypatch,
):
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
monkeypatch.setenv("APS_FALLBACK_PI_CLI", str(tmp_path / "missing-pi-cli.js"))
reply = await fallback_lane.propose_reply(
FakeStore(), "s-primary", _primary_intent("测试"))
assert reply.text == (
"智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。")
assert "暂未识别到" not in reply.text
assert "订单号或产品" not in reply.text
assert "run " not in reply.text
def test_api_chat_routes_directly_to_pi(
monkeypatch,
):
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
captured: dict[str, object] = {}
async def fake_pi(store, session_id, intent, actor="planner"):
captured["intent"] = intent.intent
captured["query"] = intent.params.get("query")
captured["history"] = intent.params.get("_history")
captured["primary"] = intent.params.get("_piPrimary")
return AgentReply(text="这是Pi Agent的直接回复,属于智能兜底。")
monkeypatch.setattr(fallback_lane, "propose_reply", fake_pi)
with TestClient(create_app()) as client:
response = client.post(
"/api/chat",
json={
"text": "你现在能干嘛",
"history": [{"role": "user", "text": "上一轮问题"}],
},
)
assert response.status_code == 200
assert "工业智核 APS 助手" in response.text
assert "理解需求" in response.text
assert "整理结果" in response.text
assert "Pi Agent" not in response.text
assert "智能兜底" not in response.text
assert '"type":"intent"' not in response.text
assert captured == {
"intent": "assistant.reply",
"query": "你现在能干嘛",
"history": [{"role": "user", "text": "上一轮问题"}],
"primary": True,
}
def test_api_chat_routes_data_and_schedule_language_to_pi(monkeypatch):
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
captured: list[tuple[str, str, bool]] = []
async def fake_pi(store, session_id, intent, actor="planner"):
captured.append((
intent.intent,
str(intent.params.get("query") or ""),
bool(intent.params.get("_piPrimary")),
))
return AgentReply(text="Pi 已接管自然语言理解")
monkeypatch.setattr(fallback_lane, "propose_reply", fake_pi)
with TestClient(create_app()) as client:
analyze = client.post("/api/chat", json={"text": "分析一下数据文件"})
schedule = client.post("/api/chat", json={"text": "根据这些数据排产"})
assert analyze.status_code == 200
assert schedule.status_code == 200
assert captured == [
("assistant.reply", "分析一下数据文件", True),
("assistant.reply", "根据这些数据排产", True),
]
def test_primary_product_copy_hides_internal_runtime_names():
reply = AgentReply(
text=(
"status: success\n\n"
"[智能兜底 · 执行计划] run fb-1234\n"
"Pi Agent执行计划已准备完成。请调用 `readiness.query`。"
),
blocks=[
UIBlock(
blockId="confirm-test",
type="confirm-card",
props={
"confirmId": "confirm-test",
"title": "Pi Agent执行计划(S2 · 1 步)",
"summary": ["智能兜底正在等待批准", "业务数据不会自动修改"],
},
)
],
)
productized = fallback_lane._productize_primary_reply(reply)
visible = "\n".join(
[
productized.text,
productized.blocks[0].props["title"],
*productized.blocks[0].props["summary"],
]
)
assert "[执行计划]" in productized.text
assert productized.blocks[0].props["title"] == "执行计划(S2 · 1 步)"
assert productized.blocks[0].props["confirmId"] == "confirm-test"
assert "Pi Agent" not in visible
assert "智能兜底" not in visible
assert "readiness.query" not in visible
assert "数据齐备度检查" in visible
assert fallback_lane._sanitize_primary_text("") == "已完成处理,但没有可展示的正文。"