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