triagesieve_env / tests /test_environment_part2.py
Angshuman28's picture
Upload folder using huggingface_hub
b89c8aa verified
Raw
History Blame Contribute Delete
38.5 kB
"""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
# ---------------------------------------------------------------------------
@pytest.fixture
def env():
"""Create a fresh TriageSieveEnvironment instance."""
from ..server.triagesieve_env_environment import TriageSieveEnvironment
return TriageSieveEnvironment()
@pytest.fixture
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
@pytest.fixture
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."""
@pytest.fixture
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={}
))