"""已完成业务动作的计划员语言说明必须走只读 Pi 叙事段。""" from __future__ import annotations import json from pathlib import Path import pytest from fastapi.testclient import TestClient from server.agent_core import fallback_lane, harness from server.auth.context import bind_identity, reset_identity from server.contracts import AgentReply from server.state.seed import seed_world from tests.auth_provider import install_test_auth class _Store: def __init__(self) -> None: self.data = seed_world() self._next = 0 def next_id(self, _kind: str) -> int: self._next += 1 return self._next def save(self) -> None: pass @pytest.fixture(autouse=True) def _clear_pending_confirmations(): harness._approval_store.clear() yield harness._approval_store.clear() def test_action_result_uses_readonly_pi_without_business_tools(tmp_path, monkeypatch): monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) captured: dict[str, object] = {} def runner(task: str, work_dir: Path): captured["task"] = task facts_path = work_dir.parent / "inbox" / "completed-action.json" raw = facts_path.read_text(encoding="utf-8") captured["payload"] = json.loads(raw.split("\n", 1)[1]) yield { "type": "message_end", "message": { "role": "assistant", "stopReason": "stop", "content": [{ "type": "text", "text": ( "status: success\n\n" "资料已经采用完成。本次保存了物料和设备资料," "下一步可以生成试排方案。" ), }], }, } yield {"type": "agent_end", "messages": []} reply = fallback_lane.propose_action_result( _Store(), "s-action-result", action="采用排产资料", fallback_text=( "排产资料已采用完成。本次写入:物料 45 项、设备 15 台。\n\n" "这次操作只保存资料,没有生成排产方案。" ), facts={ "summary": "排产资料已采用完成。", "items": [ {"label": "物料", "count": 45, "unit": "项"}, {"label": "设备", "count": 15, "unit": "台"}, ], "nextStep": "下一步可以生成试排方案。", }, runner=runner, config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert reply.text == ( "资料已经采用完成。本次保存了物料和设备资料,下一步可以生成试排方案。" ) payload = captured["payload"] assert payload["本次写入"] == [ {"label": "物料", "count": 45, "unit": "项"}, {"label": "设备", "count": 15, "unit": "台"}, ] assert "materials×45" not in captured["task"] assert "APS 业务工具" not in captured["task"] assert "aps_invoke" not in captured["task"] assert "completed-action.json" in captured["task"] def test_action_result_keeps_success_when_pi_is_unavailable(tmp_path, monkeypatch): monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) def unavailable(*_args, **_kwargs): raise fallback_lane.FallbackUnavailable("模型服务未配置") monkeypatch.setattr(fallback_lane, "build_pi_runner", unavailable) fallback_text = ( "排产资料已采用完成。本次写入:物料 45 项、设备 15 台。\n\n" "这次操作只保存资料,没有生成排产方案。" ) reply = fallback_lane.propose_action_result( _Store(), "s-action-result", action="采用排产资料", fallback_text=fallback_text, facts={ "summary": "排产资料已采用完成。", "items": [ {"label": "物料", "count": 45, "unit": "项"}, {"label": "设备", "count": 15, "unit": "台"}, ], "nextStep": "下一步可以生成试排方案。", }, config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert reply.text == fallback_text def test_action_result_rejects_internal_field_names_from_pi(tmp_path, monkeypatch): monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) def runner(_task: str, _work_dir: Path): yield { "type": "message_end", "message": { "role": "assistant", "stopReason": "stop", "content": [{ "type": "text", "text": "status: success\n\n已采用 materials×45、equipment×15。", }], }, } yield {"type": "agent_end", "messages": []} fallback_text = "排产资料已采用完成。本次写入:物料 45 项、设备 15 台。" reply = fallback_lane.propose_action_result( _Store(), "s-action-result", action="采用排产资料", fallback_text=fallback_text, facts={ "summary": "排产资料已采用完成。", "items": [ {"label": "物料", "count": 45, "unit": "项"}, {"label": "设备", "count": 15, "unit": "台"}, ], "nextStep": "下一步可以生成试排方案。", }, runner=runner, config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert reply.text == fallback_text def test_import_commit_confirmation_route_returns_pi_explanation(tmp_path, monkeypatch): monkeypatch.setenv("APS_SEED_DEMO", "0") monkeypatch.setenv("APS_HOME", str(tmp_path)) monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "data")) provider = install_test_auth(monkeypatch, "action-result-test") from server.gateway.app import create_app from server.state.store import get_store with TestClient(create_app()) as client: login = client.post( "/api/auth/login", json={"username": "planner", "password": "test"}, ) assert login.status_code == 200, login.text store = get_store() token = bind_identity(provider._identity("planner")) try: block = harness.stage_confirmation( "s-action-result", "import.commit", { "filename": "测试资料.xlsx", "analysisIngest": { "projectName": "测试项目", "sources": ["测试资料.xlsx"], }, "batches": [{ "kind": "materials", "sheet": "物料", "okCount": 1, "errorCount": 0, "okRows": [{ "code": "M-100", "name": "测试物料", "type": "RAW_MATERIAL", "unit": "kg", "stock": 0, }], }], }, title="核对并采用本次排产资料", summary_lines=["确认后保存资料,本步不会排产。"], ) finally: reset_identity(token) store.save() captured: dict[str, object] = {} def fake_result( store, session_id, *, action, fallback_text, facts, actor="planner", **_kwargs, ): captured.update({ "session_id": session_id, "action": action, "fallback_text": fallback_text, "facts": facts, "actor": actor, }) return AgentReply(text="资料已经采用,下一步可以生成试排方案。") monkeypatch.setattr(fallback_lane, "propose_action_result", fake_result) response = client.post( "/api/actions/confirm", json={ "sessionId": "s-action-result", "confirmId": block.props["confirmId"], "approve": True, }, ) assert response.status_code == 200, response.text assert response.json()["message"] == "资料已经采用,下一步可以生成试排方案。" assert captured["session_id"] == "s-action-result" assert captured["action"] == "采用排产资料" assert str(captured["fallback_text"]).startswith("排产资料已采用完成。") assert captured["facts"]["items"] == [ {"label": "物料", "count": 1, "unit": "项"}, ] assert "materials×" not in json.dumps(captured["facts"], ensure_ascii=False) assert captured["actor"] == "planner" def test_import_commit_batch_route_returns_pi_explanation(tmp_path, monkeypatch): monkeypatch.setenv("APS_SEED_DEMO", "0") monkeypatch.setenv("APS_HOME", str(tmp_path)) monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "data")) provider = install_test_auth(monkeypatch, "action-result-batch-test") from server.gateway.app import create_app from server.state.store import get_store with TestClient(create_app()) as client: login = client.post( "/api/auth/login", json={"username": "planner", "password": "test"}, ) assert login.status_code == 200, login.text store = get_store() token = bind_identity(provider._identity("planner")) try: block = harness.stage_confirmation( "s-action-result-batch", "import.commit", { "filename": "批量测试资料.xlsx", "analysisIngest": { "projectName": "批量测试项目", "sources": ["批量测试资料.xlsx"], }, "batches": [{ "kind": "equipment", "sheet": "设备", "okCount": 1, "errorCount": 0, "okRows": [{ "code": "EQ-100", "name": "测试设备", "type": "MACHINE", }], }], }, title="核对并采用本次排产资料", summary_lines=["确认后保存资料,本步不会排产。"], ) finally: reset_identity(token) store.save() captured: list[dict[str, object]] = [] def fake_result( store, session_id, *, action, fallback_text, facts, actor="planner", **_kwargs, ): captured.append({ "session_id": session_id, "action": action, "fallback_text": fallback_text, "facts": facts, "actor": actor, }) return AgentReply(text="资料已经采用,下一步可以生成试排方案。") monkeypatch.setattr(fallback_lane, "propose_action_result", fake_result) response = client.post( "/api/actions/confirm-batch", json={ "sessionId": "s-action-result-batch", "confirmIds": [block.props["confirmId"]], "approve": True, }, ) assert response.status_code == 200, response.text results = response.json()["results"] assert len(results) == 1 assert results[0]["ok"] is True assert results[0]["message"] == "资料已经采用,下一步可以生成试排方案。" assert len(captured) == 1 assert captured[0]["session_id"] == "s-action-result-batch" assert captured[0]["action"] == "采用排产资料" assert captured[0]["facts"]["items"] == [ {"label": "设备", "count": 1, "unit": "台"}, ] assert "equipment×" not in json.dumps(captured[0]["facts"], ensure_ascii=False) assert captured[0]["actor"] == "planner"