from rpa_yisaiqi.agent import JobAgent class FakeClient: def __init__(self, job, *, tasks=None): self.job = dict(job) self.tasks = list(tasks or []) self.calls = [] def get_job(self, job_id): self.calls.append(("get_job", job_id)) return {"job": dict(self.job), "tasks": list(self.tasks)} def start_node(self, job_id, node_code, **kwargs): self.calls.append(("start", node_code, kwargs)) return {"ok": True} def complete_node(self, job_id, node_code, payload): self.calls.append(("complete", node_code, payload)) return {"ok": True, "next_node": "PRODUCT_MATCH"} def fail_node(self, job_id, node_code, payload): self.calls.append(("fail", node_code, payload)) return {"ok": True, "job_status": "FAILED_RETRYABLE"} def ingest_items(self, job_id, rows, replace=True): self.calls.append(("ingest_items", job_id, rows)) return {"ok": True, "inserted": len(rows)} def match_run(self, job_id): self.calls.append(("match_run", job_id)) return {"ok": True, "next_node": "CONTRACT_CREATE"} def list_tasks(self, **kwargs): self.calls.append(("list_tasks", kwargs)) return {"items": list(self.tasks)} def submit_task(self, task_id, action, payload, submit_token, operator="agent"): self.calls.append(("submit_task", task_id, action, payload, submit_token)) return {"ok": True, "next_node": "CONTRACT_CREATE"} def check_contract_result(self, job_id, approved, **kwargs): self.calls.append(("check_result", job_id, approved)) return {"ok": True, "next_node": "REBATE_HEADER"} def retry_job(self, job_id): self.calls.append(("retry", job_id)) return {"ok": True, "node_code": self.job["current_node_code"], "attempt_no": 2} def test_agent_does_not_hardcode_next_node_after_order_fetch(): client = FakeClient({ "job_id": "JOB-1", "current_node_code": "ORDER_FETCH", "status": "PENDING", "source_type": "YYC", "supplier_name": "重庆亿昊药业", "order_date": "2026-08-12", }) agent = JobAgent(client, "JOB-1", runner_mode="mock") result = agent.step() assert result["next_node"] == "PRODUCT_MATCH" assert ("complete", "ORDER_FETCH") == (client.calls[-1][0], client.calls[-1][1]) assert not any(call[0] == "complete" and call[1] == "OCR_EXTRACT" for call in client.calls) def test_agent_runs_product_match_via_state_center_not_rpa_callback(): client = FakeClient({ "job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH", "status": "PENDING", "source_type": "YYC", }) agent = JobAgent(client, "JOB-1", runner_mode="mock") result = agent.step() assert result["next_node"] == "CONTRACT_CREATE" assert client.calls[1][0] == "match_run" assert not any(call[0] in {"start", "complete"} for call in client.calls) def test_agent_auto_human_submits_source_add_task_not_node_complete(): job_id = "YYC-重庆亿昊药业-2fa0-20260812-16abff" client = FakeClient( { "job_id": job_id, "current_node_code": "SOURCE_ADD", "status": "WAITING_USER", "source_type": "YYC", }, tasks=[{"task_id": "T-1", "node_code": "SOURCE_ADD", "status": "OPEN"}], ) agent = JobAgent(client, job_id, runner_mode="mock", auto_human=True) result = agent.step() assert result["next_node"] == "CONTRACT_CREATE" assert client.calls[-1][:3] == ("submit_task", "T-1", "CONFIRM_AND_CONTINUE") assert len(client.calls[-1][4]) <= 64 assert not any(call[0] == "complete" for call in client.calls) def test_agent_stops_on_human_node_when_auto_human_disabled(): client = FakeClient({ "job_id": "JOB-1", "current_node_code": "SOURCE_ADD", "status": "WAITING_USER", "source_type": "YYC", }) agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_human=False) result = agent.step() assert result["stopped"] == "WAITING_USER" assert not any(call[0] == "submit_task" for call in client.calls) def test_agent_contract_wait_uses_check_result_not_start_complete(): client = FakeClient({ "job_id": "JOB-1", "current_node_code": "CONTRACT_APPROVE_WAIT", "status": "WAITING_EXTERNAL", "source_type": "YYC", }) agent = JobAgent(client, "JOB-1", runner_mode="mock") result = agent.step() assert result["next_node"] == "REBATE_HEADER" assert client.calls[-1][:2] == ("check_result", "JOB-1") assert not any(call[0] in {"start", "complete"} for call in client.calls) def test_agent_retries_failed_retryable_then_stops_if_still_failed(): client = FakeClient({ "job_id": "JOB-1", "current_node_code": "ORDER_FETCH", "status": "FAILED_RETRYABLE", "source_type": "YYC", }) agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_retry=True) result = agent.step() assert ("retry", "JOB-1") in client.calls assert result["attempt_no"] == 2 class ScriptedClient(FakeClient): """Advance through canned job states after each action. Sequence is not in the agent.""" def __init__(self, frames): self.frames = list(frames) super().__init__(self.frames[0]["job"], tasks=self.frames[0].get("tasks") or []) def _advance(self): if len(self.frames) > 1: self.frames.pop(0) self.job = dict(self.frames[0]["job"]) self.tasks = list(self.frames[0].get("tasks") or []) def complete_node(self, job_id, node_code, payload): nxt = self.frames[0].get("next_node") self._advance() self.calls.append(("complete", node_code, payload)) return {"ok": True, "next_node": nxt} def match_run(self, job_id): nxt = self.frames[0].get("next_node") self._advance() self.calls.append(("match_run", job_id)) return {"ok": True, "next_node": nxt} def submit_task(self, task_id, action, payload, submit_token, operator="agent"): nxt = self.frames[0].get("next_node") self._advance() self.calls.append(("submit_task", task_id, action, payload)) return {"ok": True, "next_node": nxt} def check_contract_result(self, job_id, approved, **kwargs): nxt = self.frames[0].get("next_node") self._advance() self.calls.append(("check_result", job_id, approved)) return {"ok": True, "next_node": nxt} def test_agent_run_follows_state_center_until_success(): client = ScriptedClient([ {"job": {"job_id": "JOB-1", "current_node_code": "ORDER_FETCH", "status": "PENDING", "source_type": "YYC", "supplier_name": "重庆亿昊药业", "order_date": "2026-08-12"}, "next_node": "PRODUCT_MATCH"}, {"job": {"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH", "status": "PENDING", "source_type": "YYC"}, "next_node": "CONTRACT_CREATE"}, {"job": {"job_id": "JOB-1", "current_node_code": "CONTRACT_CREATE", "status": "PENDING", "source_type": "YYC", "order_date": "2026-08-12"}, "next_node": "CONTRACT_APPROVE_WAIT"}, {"job": {"job_id": "JOB-1", "current_node_code": "CONTRACT_APPROVE_WAIT", "status": "WAITING_EXTERNAL", "source_type": "YYC"}, "next_node": "REBATE_HEADER"}, {"job": {"job_id": "JOB-1", "current_node_code": "REBATE_HEADER", "status": "PENDING", "source_type": "YYC"}, "next_node": "REBATE_IMPORT"}, {"job": {"job_id": "JOB-1", "current_node_code": "REBATE_IMPORT", "status": "PENDING", "source_type": "YYC"}, "next_node": "REBATE_SUBMIT"}, {"job": {"job_id": "JOB-1", "current_node_code": "REBATE_SUBMIT", "status": "PENDING", "source_type": "YYC"}, "next_node": "M5_CREATE_ORDER"}, {"job": {"job_id": "JOB-1", "current_node_code": "M5_CREATE_ORDER", "status": "PENDING", "source_type": "YYC"}, "next_node": "M5_MANUAL_CONFIRM"}, {"job": {"job_id": "JOB-1", "current_node_code": "M5_MANUAL_CONFIRM", "status": "WAITING_USER", "source_type": "YYC"}, "tasks": [{"task_id": "T-M5", "node_code": "M5_MANUAL_CONFIRM", "status": "OPEN"}], "next_node": "M5_RECEIPT_NOTICE"}, {"job": {"job_id": "JOB-1", "current_node_code": "M5_RECEIPT_NOTICE", "status": "WAITING_USER", "source_type": "YYC"}, "tasks": [{"task_id": "T-SH", "node_code": "M5_RECEIPT_NOTICE", "status": "OPEN"}], "next_node": None}, {"job": {"job_id": "JOB-1", "current_node_code": "ARCHIVE_JOB", "status": "SUCCESS", "source_type": "YYC"}}, ]) agent = JobAgent(client, "JOB-1", runner_mode="mock", auto_human=True) history = agent.run() acted = [call[0] for call in client.calls if call[0] in { "complete", "match_run", "check_result", "submit_task", }] assert acted == [ "complete", "match_run", "complete", "check_result", "complete", "complete", "complete", "complete", "submit_task", "submit_task", ] assert history[-1]["stopped"] == "SUCCESS" assert not any(call[1] == "OCR_EXTRACT" for call in client.calls if call[0] == "complete")