aps-agent/tests/golden/test_pi_primary_chat.py

745 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("") == "已完成处理,但没有可展示的正文。"