Spaces:
Sleeping
Sleeping
| """Tests for triagesieve_env_environment.py Part 2 β action handler transition logic. | |
| RED phase: These tests define the contract for: | |
| - classify_ticket: opened/waiting_for_info β classified, family-subtype validation | |
| - set_impact_urgency: classified β classified (stays), stores impact/urgency | |
| - route_ticket: classified/waiting_for_info β routed, gated queue pushback | |
| - escalate_ticket: classified/waiting_for_info/routed β escalated, gated queue pushback | |
| - request_information: classified β waiting_for_info, deterministic follow-up | |
| - merge_duplicate: opened/classified β merged, duplicate validation | |
| - close_ticket: multiple sources β closed, close-reason constraints | |
| - SOP tracker advancement for each action | |
| - Invalid actions: never crash, consume budget, precise last_action_result | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from ..models import ( | |
| ActionType, | |
| CloseReason, | |
| Impact, | |
| IssueFamily, | |
| IssueSubtype, | |
| QueueId, | |
| TriageSieveAction, | |
| TaskDifficulty, | |
| TicketStatus, | |
| Urgency, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Fixtures | |
| # --------------------------------------------------------------------------- | |
| def env(): | |
| """Create a fresh TriageSieveEnvironment instance.""" | |
| from ..server.triagesieve_env_environment import TriageSieveEnvironment | |
| return TriageSieveEnvironment() | |
| def easy_env(env): | |
| """Environment after reset with seed=42, easy difficulty (1 ticket, budget=6). | |
| Returns (env, obs, ticket_id). | |
| """ | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| ticket_id = obs.inbox_summaries[0].ticket_id | |
| return env, obs, ticket_id | |
| def medium_env(env): | |
| """Environment with seed=42 medium difficulty (budget=12). | |
| Uses the second ticket (entitlement_mismatch: billing/invoice_error, no missing fields) | |
| for tests needing 5+ steps. | |
| Returns (env, obs, ticket_id). | |
| """ | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="medium") | |
| # Second ticket has no required_missing_fields β easier to close | |
| ticket_id = obs.inbox_summaries[1].ticket_id | |
| return env, obs, ticket_id | |
| def _open_ticket(env, ticket_id: str): | |
| """Helper: open a ticket and return the observation.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.OPEN_TICKET, | |
| ticket_id=ticket_id, | |
| metadata={}, | |
| )) | |
| def _classify_ticket(env, ticket_id: str, family: IssueFamily, subtype: IssueSubtype): | |
| """Helper: classify a ticket.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.CLASSIFY_TICKET, | |
| ticket_id=ticket_id, | |
| issue_family=family, | |
| issue_subtype=subtype, | |
| metadata={}, | |
| )) | |
| def _set_impact_urgency(env, ticket_id: str, impact: Impact, urgency: Urgency): | |
| """Helper: set impact and urgency.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.SET_IMPACT_URGENCY, | |
| ticket_id=ticket_id, | |
| impact=impact, | |
| urgency=urgency, | |
| metadata={}, | |
| )) | |
| def _route_ticket(env, ticket_id: str, queue_id: QueueId): | |
| """Helper: route a ticket.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.ROUTE_TICKET, | |
| ticket_id=ticket_id, | |
| queue_id=queue_id, | |
| metadata={}, | |
| )) | |
| def _request_info(env, ticket_id: str, fields: list[str]): | |
| """Helper: request information.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.REQUEST_INFORMATION, | |
| ticket_id=ticket_id, | |
| requested_fields=fields, | |
| metadata={}, | |
| )) | |
| def _escalate_ticket(env, ticket_id: str, queue_id: QueueId): | |
| """Helper: escalate a ticket.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.ESCALATE_TICKET, | |
| ticket_id=ticket_id, | |
| queue_id=queue_id, | |
| metadata={}, | |
| )) | |
| def _close_ticket(env, ticket_id: str, reason: CloseReason): | |
| """Helper: close a ticket.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.CLOSE_TICKET, | |
| ticket_id=ticket_id, | |
| close_reason=reason, | |
| metadata={}, | |
| )) | |
| def _merge_ticket(env, ticket_id: str, target_ticket_id: str): | |
| """Helper: merge a duplicate ticket.""" | |
| return env.step(TriageSieveAction( | |
| action_type=ActionType.MERGE_DUPLICATE, | |
| ticket_id=ticket_id, | |
| target_ticket_id=target_ticket_id, | |
| metadata={}, | |
| )) | |
| # --------------------------------------------------------------------------- | |
| # Β§1 classify_ticket | |
| # --------------------------------------------------------------------------- | |
| class TestClassifyTicket: | |
| """Verify classify_ticket transitions and validation.""" | |
| def test_classify_from_opened(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| # Ticket should now be classified | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLASSIFIED | |
| def test_classify_sets_last_action_result_ok(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| assert obs.last_action_result == "ok" | |
| def test_classify_correct_gives_positive_reward(self, easy_env): | |
| """Correct classification (matching hidden truth) β +0.02.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| # The easy seed=42 ticket is refund_missing_order_id: billing/refund | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| assert obs.reward == 0.02 | |
| def test_classify_wrong_gives_base_reward(self, easy_env): | |
| """Wrong classification β +0.01 (valid action, not correct).""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.BUG_REPORT) | |
| assert obs.reward == 0.01 | |
| def test_classify_logs_action(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| focused = obs.focused_ticket | |
| assert focused is not None | |
| assert any("classif" in a.lower() for a in focused.prior_actions_taken) | |
| def test_classify_from_waiting_for_info(self, env): | |
| """Re-classify after info received (waiting_for_info β classified).""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| # Request info to get to waiting_for_info | |
| _request_info(env, tid, ["order_id"]) | |
| # Re-classify should work from waiting_for_info | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLASSIFIED | |
| def test_classify_from_new_is_illegal(self, easy_env): | |
| """Cannot classify a ticket that hasn't been opened yet.""" | |
| env, obs, tid = easy_env | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| assert "Illegal action" in obs.last_action_result | |
| assert obs.reward == -0.02 | |
| def test_classify_stores_classification(self, easy_env): | |
| """Internal state records the classification for later scoring.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| # Verify via internal state (white-box check) | |
| assert tid in env._ticket_classifications | |
| assert env._ticket_classifications[tid] == (IssueFamily.BILLING, IssueSubtype.REFUND) | |
| def test_classify_advances_sop_tracker(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| tracker = env._sop_trackers[tid] | |
| # Should have advanced past the open and classify nodes | |
| assert len(tracker.visited_nodes) > 2 | |
| # --------------------------------------------------------------------------- | |
| # Β§2 set_impact_urgency | |
| # --------------------------------------------------------------------------- | |
| class TestSetImpactUrgency: | |
| """Verify set_impact_urgency behavior.""" | |
| def test_set_from_classified(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| assert obs.last_action_result == "ok" | |
| def test_status_stays_classified(self, easy_env): | |
| """set_impact_urgency does NOT transition status β ticket stays classified.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLASSIFIED | |
| def test_stores_impact_urgency(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| assert env._ticket_impact_urgency[tid] == (Impact.SINGLE_USER, Urgency.MEDIUM) | |
| def test_reward_is_base(self, easy_env): | |
| """set_impact_urgency always gives +0.01 (valid action reward).""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| assert obs.reward == 0.01 | |
| def test_set_from_opened_is_illegal(self, easy_env): | |
| """Cannot set impact/urgency before classification.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_logs_action(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _set_impact_urgency(env, tid, Impact.SINGLE_USER, Urgency.MEDIUM) | |
| assert any("impact" in a.lower() for a in obs.focused_ticket.prior_actions_taken) | |
| # --------------------------------------------------------------------------- | |
| # Β§3 route_ticket | |
| # --------------------------------------------------------------------------- | |
| class TestRouteTicket: | |
| """Verify route_ticket transitions and gated queue pushback.""" | |
| def test_route_from_classified(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ROUTED | |
| def test_route_result_ok(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| assert obs.last_action_result == "ok" | |
| def test_route_reward(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| assert obs.reward == 0.01 | |
| def test_route_before_classify_is_illegal(self, easy_env): | |
| """Β§12: cannot route before classification.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_route_stores_queue(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| assert env._ticket_routed_to[tid] == QueueId.REFUND_TEAM | |
| def test_gated_queue_pushback_without_prerequisites(self, env): | |
| """Β§15: routing to tech_support_l2 without impact/urgency β pushback.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| # No impact/urgency set β pushback | |
| obs = _route_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| assert "Pushback" in obs.last_action_result | |
| # Ticket stays classified (not routed) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLASSIFIED | |
| def test_gated_queue_pushback_penalty(self, env): | |
| """Pushback costs -0.03.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| obs = _route_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| assert obs.reward == -0.03 | |
| def test_gated_queue_succeeds_with_prerequisites(self, env): | |
| """Routing to gated queue works when prerequisites are met.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| _set_impact_urgency(env, tid, Impact.ORG_WIDE, Urgency.CRITICAL) | |
| obs = _route_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| assert obs.last_action_result == "ok" | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ROUTED | |
| def test_gated_security_team_pushback(self, env): | |
| """Β§15: routing to security_team without prerequisites β pushback.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.SECURITY, IssueSubtype.SUSPICIOUS_LOGIN) | |
| obs = _route_ticket(env, tid, QueueId.SECURITY_TEAM) | |
| assert "Pushback" in obs.last_action_result | |
| def test_route_from_waiting_for_info(self, env): | |
| """Can route from waiting_for_info status.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _request_info(env, tid, ["order_id"]) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ROUTED | |
| def test_route_logs_action(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _route_ticket(env, tid, QueueId.REFUND_TEAM) | |
| assert any("route" in a.lower() for a in obs.focused_ticket.prior_actions_taken) | |
| # --------------------------------------------------------------------------- | |
| # Β§4 escalate_ticket | |
| # --------------------------------------------------------------------------- | |
| class TestEscalateTicket: | |
| """Verify escalate_ticket transitions and gated queue pushback.""" | |
| def test_escalate_from_classified(self, env): | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| _set_impact_urgency(env, tid, Impact.ORG_WIDE, Urgency.CRITICAL) | |
| obs = _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ESCALATED | |
| assert obs.last_action_result == "ok" | |
| def test_escalate_from_routed(self, env): | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| # Gated queue but routed status means classification_set=True, | |
| # but impact_urgency might not be set β pushback | |
| # Actually gated queue requires both classification_set AND impact_urgency_set | |
| assert "Pushback" in obs.last_action_result | |
| def test_escalate_from_routed_with_prereqs(self, medium_env): | |
| """Escalate from routed with prerequisites met (needs budget > 4).""" | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| _set_impact_urgency(env, tid, Impact.ORG_WIDE, Urgency.CRITICAL) | |
| _route_ticket(env, tid, QueueId.TECH_SUPPORT_L1) | |
| obs = _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ESCALATED | |
| def test_escalate_gated_pushback(self, env): | |
| """Escalate to gated queue without prerequisites β pushback.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| # No impact/urgency β pushback | |
| obs = _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| assert "Pushback" in obs.last_action_result | |
| assert obs.reward == -0.03 | |
| def test_escalate_non_gated_queue(self, env): | |
| """Escalate to non-gated queue works without special prerequisites.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _escalate_ticket(env, tid, QueueId.BILLING_TEAM) | |
| assert obs.last_action_result == "ok" | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.ESCALATED | |
| def test_escalate_reward(self, env): | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _escalate_ticket(env, tid, QueueId.BILLING_TEAM) | |
| assert obs.reward == 0.01 | |
| def test_escalate_from_opened_is_illegal(self, easy_env): | |
| """Cannot escalate before classification.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _escalate_ticket(env, tid, QueueId.BILLING_TEAM) | |
| assert "Illegal action" in obs.last_action_result | |
| # --------------------------------------------------------------------------- | |
| # Β§5 request_information | |
| # --------------------------------------------------------------------------- | |
| class TestRequestInformation: | |
| """Verify request_information and deterministic follow-up generation.""" | |
| def test_request_info_transitions_to_waiting(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.WAITING_FOR_INFO | |
| def test_correct_fields_reward(self, easy_env): | |
| """Correct info request (matching required_missing_fields) β +0.03.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| assert obs.reward == 0.03 | |
| def test_wrong_fields_base_reward(self, easy_env): | |
| """Wrong fields requested β +0.01 (valid action, not correct).""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["wrong_field"]) | |
| assert obs.reward == 0.01 | |
| def test_correct_fields_generates_follow_up(self, easy_env): | |
| """When correct fields requested, follow-up message is appended to thread.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| # Thread history should have grown | |
| assert obs.focused_ticket is not None | |
| thread = obs.focused_ticket.thread_history | |
| # At least one message from customer follow-up | |
| customer_msgs = [m for m in thread if m.get("role") == "customer"] | |
| assert len(customer_msgs) >= 1 | |
| def test_correct_fields_sets_info_received(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _request_info(env, tid, ["order_id"]) | |
| assert env._ticket_info_received[tid] is True | |
| def test_wrong_fields_does_not_set_info_received(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _request_info(env, tid, ["wrong_field"]) | |
| assert env._ticket_info_received[tid] is False | |
| def test_superset_fields_also_correct(self, easy_env): | |
| """Superset of required fields β also counts as correct.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id", "extra_field"]) | |
| assert obs.reward == 0.03 | |
| assert env._ticket_info_received[tid] is True | |
| def test_request_info_stores_fields(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| _request_info(env, tid, ["order_id"]) | |
| assert env._ticket_info_requested[tid] == ["order_id"] | |
| def test_request_info_from_opened_is_illegal(self, easy_env): | |
| """Cannot request info before classification.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_request_info_result_ok(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| assert obs.last_action_result == "ok" | |
| def test_request_info_logs_action(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _request_info(env, tid, ["order_id"]) | |
| assert any("request" in a.lower() for a in obs.focused_ticket.prior_actions_taken) | |
| # --------------------------------------------------------------------------- | |
| # Β§6 merge_duplicate | |
| # --------------------------------------------------------------------------- | |
| class TestMergeDuplicate: | |
| """Verify merge_duplicate validation and transitions.""" | |
| def dup_env(self, env): | |
| """Medium episode with seed=1 which contains a duplicate ticket. | |
| Returns (env, obs, dup_ticket_id, dup_target_id). | |
| """ | |
| obs = env.reset(seed=1, mode="eval_strict", difficulty="medium") | |
| # seed=1 medium β first ticket is duplicate_complaint | |
| dup_tid = obs.inbox_summaries[0].ticket_id | |
| dup_target = env._ticket_index[dup_tid].hidden_truth.duplicate_of | |
| return env, obs, dup_tid, dup_target | |
| def test_merge_valid_duplicate_from_opened(self, dup_env): | |
| """Merge a ticket confirmed as duplicate with correct target.""" | |
| env, obs, dup_tid, target_tid = dup_env | |
| _open_ticket(env, dup_tid) | |
| obs = _merge_ticket(env, dup_tid, target_tid) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == dup_tid) | |
| assert summary.status == TicketStatus.MERGED | |
| def test_merge_non_duplicate_fails(self, easy_env): | |
| """Merging a ticket that is NOT a duplicate β error.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _merge_ticket(env, tid, "T-fake-target") | |
| assert obs.last_action_result != "ok" | |
| assert obs.reward == -0.02 | |
| def test_merge_wrong_target_fails(self, dup_env): | |
| """Merge with wrong target_ticket_id β error.""" | |
| env, obs, dup_tid, _target = dup_env | |
| _open_ticket(env, dup_tid) | |
| obs = _merge_ticket(env, dup_tid, "T-wrong-target") | |
| assert obs.last_action_result != "ok" | |
| assert obs.reward == -0.02 | |
| def test_merge_from_new_is_illegal(self, easy_env): | |
| """Cannot merge a ticket in NEW status.""" | |
| env, obs, tid = easy_env | |
| obs = _merge_ticket(env, tid, "T-fake") | |
| assert "Illegal action" in obs.last_action_result | |
| def test_merge_is_terminal(self, dup_env): | |
| """Merged ticket cannot have further actions.""" | |
| env, obs, dup_tid, target_tid = dup_env | |
| _open_ticket(env, dup_tid) | |
| _merge_ticket(env, dup_tid, target_tid) | |
| obs = _classify_ticket(env, dup_tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_merge_stores_target(self, dup_env): | |
| env, obs, dup_tid, target_tid = dup_env | |
| _open_ticket(env, dup_tid) | |
| _merge_ticket(env, dup_tid, target_tid) | |
| assert env._ticket_merged_to[dup_tid] == target_tid | |
| # --------------------------------------------------------------------------- | |
| # Β§7 close_ticket | |
| # --------------------------------------------------------------------------- | |
| class TestCloseTicket: | |
| """Verify close_ticket constraints and transitions.""" | |
| def test_close_from_routed(self, medium_env): | |
| """Standard close path: routed β closed (no missing fields).""" | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| def test_close_result_ok(self, medium_env): | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert obs.last_action_result == "ok" | |
| def test_close_from_escalated(self, medium_env): | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| _set_impact_urgency(env, tid, Impact.ORG_WIDE, Urgency.CRITICAL) | |
| _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| def test_close_non_actionable_from_opened(self, env): | |
| """Β§12: can close from opened ONLY if non-actionable.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| obs = _close_ticket(env, tid, CloseReason.NON_ACTIONABLE) | |
| assert obs.last_action_result == "ok" | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| def test_close_resolved_from_opened_fails(self, easy_env): | |
| """Β§12: closing from opened with reason=resolved is not allowed (only non-actionable).""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert obs.last_action_result != "ok" | |
| assert obs.reward == -0.02 | |
| def test_close_with_missing_fields_unfulfilled(self, easy_env): | |
| """Β§12 hard rule: cannot close while required_missing_fields unfulfilled.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| # Don't request info β missing fields still unfulfilled | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert obs.last_action_result != "ok" | |
| assert obs.reward == -0.02 | |
| def test_close_non_actionable_exempt_from_missing_fields(self, easy_env): | |
| """Non-actionable close bypasses missing fields check.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _close_ticket(env, tid, CloseReason.NON_ACTIONABLE) | |
| assert obs.last_action_result == "ok" | |
| def test_close_duplicate_exempt_from_missing_fields(self, easy_env): | |
| """Duplicate close bypasses missing fields check.""" | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.REFUND) | |
| obs = _close_ticket(env, tid, CloseReason.DUPLICATE) | |
| assert obs.last_action_result == "ok" | |
| def test_close_stores_reason(self, medium_env): | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert env._ticket_close_reasons[tid] == CloseReason.RESOLVED | |
| def test_close_is_terminal(self, medium_env): | |
| """Closed ticket allows no further actions.""" | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| _close_ticket(env, tid, CloseReason.RESOLVED) | |
| obs = _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_close_from_new_is_illegal(self, easy_env): | |
| env, obs, tid = easy_env | |
| obs = _close_ticket(env, tid, CloseReason.NON_ACTIONABLE) | |
| assert "Illegal action" in obs.last_action_result | |
| def test_close_reward(self, medium_env): | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert obs.reward == 0.01 | |
| def test_close_logs_action(self, medium_env): | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert any("close" in a.lower() for a in obs.focused_ticket.prior_actions_taken) | |
| # --------------------------------------------------------------------------- | |
| # Β§8 Budget and Invalid Action Behavior | |
| # --------------------------------------------------------------------------- | |
| class TestInvalidActionBehavior: | |
| """Verify that invalid actions never crash, always consume budget.""" | |
| def test_invalid_action_does_not_crash(self, easy_env): | |
| """Invalid transition does not raise an exception.""" | |
| env, obs, tid = easy_env | |
| # Try to route a NEW ticket (illegal) | |
| obs = _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| assert obs is not None | |
| assert obs.reward == -0.02 | |
| def test_invalid_action_consumes_budget(self, easy_env): | |
| env, obs, tid = easy_env | |
| initial_budget = obs.action_budget_remaining | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs2 = env.step(TriageSieveAction( | |
| action_type=ActionType.SKIP_TURN, metadata={} | |
| )) | |
| # Budget should have decreased by 2 (one invalid + one skip) | |
| assert obs2.action_budget_remaining == initial_budget - 2 | |
| def test_pushback_does_not_crash(self, env): | |
| """Pushback from gated queue does not crash.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| obs = _route_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| assert obs is not None | |
| assert "Pushback" in obs.last_action_result | |
| def test_merge_invalid_does_not_crash(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _merge_ticket(env, tid, "nonexistent") | |
| assert obs is not None | |
| assert obs.last_action_result != "ok" | |
| def test_close_invalid_does_not_crash(self, easy_env): | |
| env, obs, tid = easy_env | |
| _open_ticket(env, tid) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| assert obs is not None | |
| assert obs.last_action_result != "ok" | |
| # --------------------------------------------------------------------------- | |
| # Β§9 Full Workflow Integration | |
| # --------------------------------------------------------------------------- | |
| class TestFullWorkflow: | |
| """End-to-end workflow tests combining multiple actions.""" | |
| def test_happy_path_no_missing_fields(self, medium_env): | |
| """Complete happy path: open β classify β route β close (no missing fields).""" | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.BILLING, IssueSubtype.INVOICE_ERROR) | |
| _route_ticket(env, tid, QueueId.BILLING_TEAM) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| assert obs.last_action_result == "ok" | |
| def test_escalation_path(self, medium_env): | |
| """open β classify β set impact/urgency β escalate β close.""" | |
| env, obs, tid = medium_env | |
| _open_ticket(env, tid) | |
| _classify_ticket(env, tid, IssueFamily.TECHNICAL, IssueSubtype.INTEGRATION_FAILURE) | |
| _set_impact_urgency(env, tid, Impact.ORG_WIDE, Urgency.CRITICAL) | |
| _escalate_ticket(env, tid, QueueId.TECH_SUPPORT_L2) | |
| obs = _close_ticket(env, tid, CloseReason.RESOLVED) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| def test_non_actionable_shortcut(self, env): | |
| """open β close(non_actionable) β shortest valid path for spam.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| tid = obs.inbox_summaries[0].ticket_id | |
| _open_ticket(env, tid) | |
| obs = _close_ticket(env, tid, CloseReason.NON_ACTIONABLE) | |
| summary = next(s for s in obs.inbox_summaries if s.ticket_id == tid) | |
| assert summary.status == TicketStatus.CLOSED | |
| def test_budget_exhaustion_ends_episode(self, env): | |
| """Episode ends when action budget reaches 0.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| # Easy budget = 6 | |
| for _ in range(6): | |
| obs = env.step(TriageSieveAction( | |
| action_type=ActionType.SKIP_TURN, metadata={} | |
| )) | |
| assert obs.done is True | |
| def test_actions_after_done_raises(self, env): | |
| """Cannot step after episode is done.""" | |
| obs = env.reset(seed=42, mode="eval_strict", difficulty="easy") | |
| for _ in range(6): | |
| obs = env.step(TriageSieveAction( | |
| action_type=ActionType.SKIP_TURN, metadata={} | |
| )) | |
| with pytest.raises(RuntimeError, match="done"): | |
| env.step(TriageSieveAction( | |
| action_type=ActionType.SKIP_TURN, metadata={} | |
| )) | |