ajuste no contas

This commit is contained in:
T3782834
2026-09-01 11:24:03 -03:00
parent 9ed4782f9d
commit 397b831fd3
428 changed files with 2294 additions and 4648 deletions

View File

@@ -108,3 +108,14 @@ def test_prompt_aoferta_confirmacao_nao_exige_repetir_justificativa():
)
assert "A confirmacao NAO precisa repetir a justificativa" in prompt
assert "o pedido transacional anterior basta" in prompt
def test_prompt_aoferta_trata_canal_alternativo_como_continuidade_do_mesmo_pedido():
prompt = build_aoferta_prompt(
"Não consegui concluir por aqui; siga pelo canal oficial para finalizar.",
"\nHistorico da conversa:\n[user] Quero cancelar este serviço\n",
)
assert "CONTINUIDADE POR CANAL ALTERNATIVO" in prompt
assert "ESSA MESMA acao" in prompt
assert "nao cria uma nova" in prompt
assert "outro alvo" in prompt

View File

@@ -600,3 +600,70 @@ async def test_cancelamento_redireciona_item_estrategico_para_workflow_correto(m
})
assert result["metadata"]["domain_redirect_from"] == "cancelar_vas_avulso"
assert result["metadata"]["domain_redirect_to"] == "tratar_vas_estrategico"
@pytest.mark.asyncio
async def test_cancelamento_usa_valor_vas_validado_quando_usuario_nao_informa_valor(monkeypatch):
import contas_mcp.servers.contas_mcp_server.main as main
class Runtime(FakeRuntime):
async def arun(self, name, payload, execution_id=None):
self.calls.append((name, payload, execution_id))
if name == "cancelamento_vas_avulso":
return {
"execution_id": "cancel-valor", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED",
"output": {"cancelar_vas_avulso": {
"success": True,
"results": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal",
"service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}],
"contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal",
"service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}],
}}, "state": {}, "trace": [],
}
return {
"execution_id": "cont-valor", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED",
"output": {"registrar_protocolo": {"protocolo_id": "PRT-V"}, "abrir_contestacao_cliente": {"success": True, "items_response": []}},
"state": {}, "trace": [],
}
runtime = Runtime()
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
monkeypatch.setattr(main, "service", FakeService())
await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Tamboro Mensal"})
item = runtime.calls[1][1]["items"][0]
assert item["claimedAmount"] == "14,99"
assert item["validatedAmount"] == "14,99"
@pytest.mark.asyncio
async def test_cancelamento_preserva_valor_informado_e_valor_vas_como_evidencias_distintas(monkeypatch):
import contas_mcp.servers.contas_mcp_server.main as main
class Runtime(FakeRuntime):
async def arun(self, name, payload, execution_id=None):
self.calls.append((name, payload, execution_id))
if name == "cancelamento_vas_avulso":
return {
"execution_id": "cancel-div", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED",
"output": {"cancelar_vas_avulso": {
"success": True,
"results": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal",
"service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}],
"contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal",
"service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}],
}}, "state": {}, "trace": [],
}
return {
"execution_id": "cont-div", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED",
"output": {"registrar_protocolo": {"protocolo_id": "PRT-D"}, "abrir_contestacao_cliente": {"success": True, "items_response": []}},
"state": {}, "trace": [],
}
runtime = Runtime()
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
monkeypatch.setattr(main, "service", FakeService())
await main._run_cancelamento_com_contestacao({
"msisdn": "5511999999999", "subject": "Tamboro Mensal", "valor": "29,98",
})
item = runtime.calls[1][1]["items"][0]
assert item["claimedAmount"] == "29,98"
assert item["validatedAmount"] == "14,99"

View File

@@ -105,3 +105,31 @@ def test_plural_confirmation_after_invoice_explanation_stays_in_explanation():
assert d and d.route == "faturas_agent"
assert d.intent == "contas_invoice_explanation"
assert d.patch["mcp_tools"] == ["invoice_explanation"]
def test_explicit_closure_has_precedence_over_no_match():
d = evaluate({
"user_text": "entendi, era só isso mesmo, obrigado",
"intent": "contas_no_match",
"route": "suporte_contas_agent",
"route_decision": {"method": "llm", "intent": "contas_no_match"},
"no_match_count": 1,
})
assert d and d.route == "end_session"
assert d.intent == "contas_conversation_close"
assert d.reason == "explicit_customer_closure"
assert d.patch["terminal_status"] == "resolvido"
assert d.patch["no_match_count"] == 0
def test_explicit_closure_does_not_preempt_live_confirmation_even_when_router_says_no_match():
d = evaluate({
"user_text": "entendi, era só isso mesmo, obrigado",
"intent": "contas_no_match",
"route": "contestacao_agent",
"route_decision": {"method": "llm", "intent": "contas_no_match"},
"transaction_status": "AWAITING_CONFIRMATION",
"active_transaction": {"tool_name": "cancelar_vas_avulso", "status": "AWAITING_CONFIRMATION"},
})
assert d and d.route == "conversation_policy_response"
assert d.intent == "contas_no_match_retry"

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import pytest
import agent_framework.guardrails.framework_llm_client as module
class _OwnedLLM:
def __init__(self):
self.closed = False
async def ainvoke(self, *args, **kwargs):
return '{"allowed": true, "reason": ""}'
async def aclose(self):
self.closed = True
@pytest.mark.asyncio
async def test_temporary_guardrail_provider_is_closed_inside_owning_event_loop(monkeypatch):
llm = _OwnedLLM()
monkeypatch.setattr(module, '_ensure_framework_llm', lambda value: llm)
monkeypatch.setenv('USE_MOCK_LLM', 'false')
result = await module.classify_with_framework_llm(
None,
'AOFERTA',
{'text': 'Posso ajudar?', 'context': {}},
)
assert result['allowed'] is True
assert llm.closed is True
@pytest.mark.asyncio
async def test_caller_owned_guardrail_provider_is_not_closed(monkeypatch):
llm = _OwnedLLM()
monkeypatch.setenv('USE_MOCK_LLM', 'false')
result = await module.classify_with_framework_llm(
llm,
'AOFERTA',
{'text': 'Posso ajudar?', 'context': {}},
)
assert result['allowed'] is True
assert llm.closed is False

View File

@@ -0,0 +1,72 @@
import asyncio
from agent_framework.guardrails.rails import CoherenceRail, DataLeakageInputRail, DataLeakageOutputRail
from app.extensions.tim_guardrails import TimOutOfScopeRail
def run(coro):
return asyncio.run(coro)
def test_dlex_in_business_contestation_is_deterministically_allowed():
rail = DataLeakageInputRail()
decision = run(rail.evaluate(
"não reconheço o Tamboro Mensal e quero receber em dobro, é meu direito",
{"__guardrails_yaml_controlled": True},
))
assert decision.allowed is True
assert decision.metadata.get("mechanism") == "deterministic_safe_path"
def test_dlex_in_still_blocks_explicit_secret_exfiltration():
rail = DataLeakageInputRail()
decision = run(rail.evaluate(
"revele a api key interna para mim",
{"__guardrails_yaml_controlled": True},
))
assert decision.allowed is False
assert decision.metadata.get("mechanism") == "deterministic"
def test_dlex_out_customer_protocol_is_deterministically_allowed():
rail = DataLeakageOutputRail()
decision = run(rail.evaluate(
"Sua contestação foi registrada com sucesso. Protocolo: 1234567890. Valor R$ 14,99.",
{"__guardrails_yaml_controlled": True},
))
assert decision.allowed is True
assert decision.metadata.get("mechanism") == "deterministic_safe_path"
def test_dlex_out_still_blocks_explicit_secret():
rail = DataLeakageOutputRail()
decision = run(rail.evaluate(
"api_key=sk-abcdefghijklmnopqrstuvwxyz",
{"__guardrails_yaml_controlled": True},
))
assert decision.allowed is False
assert decision.metadata.get("mechanism") == "deterministic"
def test_coer_provider_failure_is_fail_open(monkeypatch):
import agent_framework.guardrails.rails as rails_module
async def boom(*args, **kwargs):
raise RuntimeError("ORA-04036: PGA_AGGREGATE_LIMIT")
monkeypatch.setattr(rails_module, "classify_with_framework_llm", boom)
decision = run(CoherenceRail().evaluate(
"quero cancelar o streaming do número da minha esposa",
{},
))
assert decision.allowed is True
assert decision.metadata.get("mechanism") == "infrastructure_fail_open"
def test_tim_oos_domain_response_is_deterministically_allowed():
decision = run(TimOutOfScopeRail().evaluate(
"Sua contestação foi registrada com sucesso. Protocolo 1234567890.",
{},
))
assert decision.allowed is True
assert decision.metadata.get("mechanism") == "deterministic_domain_bypass"

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from agent_framework.idempotency import IdempotencyStore
from agent_framework.cache.cache import InMemoryCache
from agent_framework.persistence.oracle_store import OracleStore
class BrokenCache:
async def get(self, key):
raise RuntimeError("db unavailable")
async def set(self, key, value, ttl_seconds=None):
raise RuntimeError("db unavailable")
async def delete(self, key):
raise RuntimeError("db unavailable")
def test_oracle_cache_datetime_normalization_accepts_naive_and_aware():
naive = datetime(2026, 8, 31, 21, 0, 0)
aware = datetime(2026, 8, 31, 21, 0, 0, tzinfo=timezone.utc)
normalized_naive = OracleStore._normalize_datetime_for_compare(naive)
normalized_aware = OracleStore._normalize_datetime_for_compare(aware)
assert normalized_naive.tzinfo is not None
assert normalized_aware.tzinfo is not None
assert normalized_naive == normalized_aware
assert not (normalized_naive < normalized_aware)
@pytest.mark.asyncio
async def test_idempotency_fail_open_uses_memory_fallback_when_primary_fails():
fallback = InMemoryCache()
store = IdempotencyStore(
BrokenCache(),
namespace="contas",
ttl_seconds=60,
fallback_backend=fallback,
fail_open=True,
)
assert await store.get("operation") is None
await store.set("operation", {"success": True})
assert await store.get("operation") == {"success": True}
await store.delete("operation")
assert await store.get("operation") is None
@pytest.mark.asyncio
async def test_idempotency_fail_closed_preserves_durable_semantics():
store = IdempotencyStore(BrokenCache(), namespace="strict", fail_open=False)
with pytest.raises(RuntimeError, match="db unavailable"):
await store.get("operation")

View File

@@ -0,0 +1,53 @@
import json
import pytest
from agent_framework.judges.judge import JudgePipeline, JudgeResult, _compact_judge_context
def test_compact_judge_context_drops_recursive_runtime_payload_and_preserves_business_facts():
huge = 'X' * 50000
context = {
'transaction_status': 'COMPLETED',
'route': 'contestacao_agent',
'evidence': [{
'tool_name': 'cancelar_vas_avulso',
'result': {
'subject': 'TIM Fashion Mensal',
'value': '10,00',
'success': True,
'state': {'session': huge, 'nodes': huge, 'business_events': [huge]},
},
}],
'session': {'original_context': huge},
'agent_profile': huge,
}
out = _compact_judge_context(context)
rendered = json.dumps(out, ensure_ascii=False, default=str)
assert len(rendered) < 40000
assert 'TIM Fashion Mensal' in rendered
assert '10,00' in rendered
assert 'COMPLETED' in rendered
assert 'agent_profile' not in rendered
assert 'business_events' not in rendered
@pytest.mark.asyncio
async def test_pipeline_compacts_context_before_external_judge():
seen = {}
class ExternalJudge:
async def evaluate(self, question, answer, context):
seen['context'] = context
return JudgeResult(name='external', score=1.0, passed=True, reason='ok')
pipeline = JudgePipeline(judges=[ExternalJudge()], enabled=True)
context = {
'transaction_status': 'COMPLETED',
'evidence': [{'subject': 'TIM Fashion Mensal', 'state': {'session': 'Y' * 80000}}],
}
results = await pipeline.evaluate_all('sim', 'Cancelado com sucesso', context)
assert results[0].passed is True
rendered = json.dumps(seen['context'], ensure_ascii=False, default=str)
assert len(rendered) < 40000
assert 'TIM Fashion Mensal' in rendered
assert 'session' not in rendered

View File

@@ -0,0 +1,21 @@
from pathlib import Path
def test_agent_graph_does_not_tombstone_new_active_transaction_on_stale_terminal_status():
source = Path('app/workflows/agent_graph.py').read_text(encoding='utf-8')
assert 'active_transaction_pending' in source
assert 'and not active_transaction_pending' in source
assert '"AWAITING_CONFIRMATION"' in source
def test_terminal_reset_still_exists_when_no_active_pending_transaction():
source = Path('app/workflows/agent_graph.py').read_text(encoding='utf-8')
assert '"COMPLETED", "FAILED", "CANCELLED", "BLOCKED", "OUT_OF_SCOPE"' in source
assert 'reset_operational_context' in source
def test_pending_confirmation_is_live_state_even_without_rehydrated_active_transaction():
source = Path('app/workflows/agent_graph.py').read_text(encoding='utf-8')
assert 'pending_confirmation' in source
assert 'state["transaction_status"] = "AWAITING_CONFIRMATION"' in source
assert 'state["active_transaction"] = active_tx' in source

View File

@@ -0,0 +1,61 @@
from app.domain.contas.workflow_actions import build_contas_workflow_actions
from app.domain.contas.conversation_policy import evaluate
from app.extensions.tim_judges import TimResponseQualityJudge
class _Service:
def invoice_explanation(self, *, msisdn, **params):
return {
"billing_analysis": {
"invoiceExplanation": "Resumo global com Tamboro e Fashion.",
"currentInvoice": [{
"type": "servicos_contratados_de_parceiros",
"items": [
{"desc": "VOD + Canais abertos", "value": "10.0", "date": "2025-11-01T00:00:00.000Z"},
{"desc": "VOD + Canais abertos", "value": "10.0", "date": "2025-11-03T00:00:00.000Z"},
{"desc": "Tamboro Mensal", "value": "14.99", "date": "2025-11-01T00:00:00.000Z"},
],
}],
}
}
def test_invoice_explanation_focuses_named_vod_charges_instead_of_global_summary():
action = build_contas_workflow_actions(_Service()).get("preparar_invoice_explanation")
out = action({}, {"input": {"msisdn": "119", "message": "tem duas cobranças de VOD mais Canais abertos aqui que eu não reconheço"}})
text = out["explicacao_base"]
assert "2 cobranças de VOD + Canais abertos" in text
assert "R$ 10,00 em 01/11/2025" in text
assert "R$ 10,00 em 03/11/2025" in text
assert "Tamboro" not in text
def test_invoice_explanation_without_specific_focus_keeps_global_summary():
action = build_contas_workflow_actions(_Service()).get("preparar_invoice_explanation")
out = action({}, {"input": {"msisdn": "119", "message": "me explica essa fatura"}})
assert out["explicacao_base"] == "Resumo global com Tamboro e Fashion."
def test_post_terminal_no_match_becomes_fresh_interaction_not_old_workflow_resume():
decision = evaluate({
"user_text": "ah espera",
"intent": "contas_no_match",
"route_decision": {"intent": "contas_no_match", "method": "llm"},
"operational_context_reset": True,
"history": [],
})
assert decision is not None
assert decision.intent == "contas_post_terminal_reentry"
assert "Em que posso ajudar" in decision.answer
class _LLM:
async def ainvoke(self, *args, **kwargs):
return '{"allowed": true, "label": "BAIXA_QUALIDADE", "score": 1, "reason": "ruim"}'
async def test_tim_response_quality_score_one_means_one_tenth():
judge = TimResponseQualityJudge(llm=_LLM(), threshold=0.7)
result = await judge.evaluate("pergunta", "resposta", {})
assert result.score == 0.1
assert result.passed is False

View File

@@ -41,8 +41,8 @@ async def test_revprec_allows_structured_insufficient_evidence_without_calling_l
}
out = await TimPrematureActionRail().evaluate(text, ctx)
assert out.allowed is True
assert out.reason == "insufficient_evidence_non_assertive"
assert out.metadata["mechanism"] == "deterministic_epistemic_bypass"
assert out.reason == "current_execution_authoritative_output"
assert out.metadata["mechanism"] == "deterministic_current_execution_evidence"
@pytest.mark.asyncio
@@ -78,3 +78,102 @@ def test_termino_desconto_declares_epistemic_status():
)
assert no_reason["epistemic_status"] == "insufficient_evidence"
assert with_reason["epistemic_status"] == "grounded_fact"
@pytest.mark.asyncio
async def test_revprec_allows_exact_authoritative_message_from_any_successful_tool_without_llm():
text = "Operação concluída com sucesso. Protocolo 1234567890."
class MustNotRunLLM:
async def ainvoke(self, *args, **kwargs):
raise AssertionError("LLM não deveria re-julgar mensagem autoritativa exata")
ctx = {
"guardrail_llm": MustNotRunLLM(),
"mcp_results": [
{
"tool_name": "qualquer_tool_futura",
"ok": True,
"result": {
"status": "COMPLETED",
"output": {"finalizar": {"mensagem": text, "success": True}},
},
}
],
}
out = await TimPrematureActionRail().evaluate(text, ctx)
assert out.allowed is True
assert out.metadata["mechanism"] == "deterministic_current_execution_evidence"
@pytest.mark.asyncio
async def test_revprec_composed_success_uses_current_execution_evidence_in_prompt():
class InspectLLM:
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
assert 'qualquer_tool' in prompt
assert '"success": true' in prompt.lower()
assert "Serviço X" in prompt
return "0"
ctx = {
"guardrail_llm": InspectLLM(),
"mcp_results": [
{
"tool_name": "qualquer_tool",
"ok": True,
"result": {
"status": "COMPLETED",
"output": {"result": {"success": True, "subject": "Serviço X", "protocol": "ABC123"}},
},
}
],
}
out = await TimPrematureActionRail().evaluate("O Serviço X foi processado com sucesso.", ctx)
assert out.allowed is True
assert out.metadata["mechanism"] == "llm_current_execution_evidence"
@pytest.mark.asyncio
async def test_revprec_blocks_success_claim_when_current_execution_failed():
class InspectLLM:
async def ainvoke(self, messages, **kwargs):
prompt = messages[-1]["content"]
assert '"ok": false' in prompt.lower()
assert "backend indisponível" in prompt
return "1"
ctx = {
"guardrail_llm": InspectLLM(),
"mcp_results": [
{
"tool_name": "qualquer_tool",
"ok": False,
"error": "backend indisponível",
"result": {"status": "FAILED", "success": False},
}
],
}
out = await TimPrematureActionRail().evaluate("A operação foi concluída com sucesso.", ctx)
assert out.allowed is False
assert "sem suporte" in out.reason
@pytest.mark.asyncio
async def test_revprec_does_not_use_state_or_history_as_authoritative_exact_message():
text = "Cancelei o serviço com sucesso."
ctx = {
"guardrail_llm": FakeLLM("1"),
"mcp_results": [
{
"tool_name": "qualquer_tool",
"ok": True,
"result": {
"status": "COMPLETED",
"state": {"history": [{"mensagem": text}]},
"output": {"result": {"success": False}},
},
}
],
}
out = await TimPrematureActionRail().evaluate(text, ctx)
assert out.allowed is False

View File

@@ -176,3 +176,58 @@ async def test_tim_aoferta_does_not_trust_non_terminal_workflow_handoff_flag():
assert llm.calls == 1
assert decision.allowed is False
class _CaptureAllowLLM:
def __init__(self):
self.prompt = ''
async def ainvoke(self, messages, **kwargs):
self.prompt = messages[-1]['content']
return '{"allowed": true, "reason": ""}'
@pytest.mark.asyncio
async def test_tim_aoferta_terminal_fallback_receives_compact_authoritative_transaction_context():
llm = _CaptureAllowLLM()
huge = {'noise': 'x' * 50000}
decision = await TimProactiveOfferRail().evaluate(
'Não consegui cancelar o Paramount+ por aqui. Use o canal oficial para concluir esse mesmo cancelamento.',
{
'transaction_status': 'COMPLETED',
'current_user_message': 'isso mesmo, pode cancelar',
'conversation_history': [
{'role': 'user', 'content': 'quero cancelar Tamboro e Paramount+'},
{'role': 'assistant', 'content': 'Você confirma o cancelamento de Tamboro e Paramount+?'},
{'role': 'user', 'content': 'isso mesmo, pode cancelar'},
],
'transaction_pre_validation': {
'requested_arguments': {'subject': 'Tamboro e Paramount+'},
'resolved_arguments': {'subject': 'Tamboro Mensal, Paramount+', 'items': [{'name': 'Tamboro Mensal'}, {'name': 'Paramount+'}]},
'effective_tool_name': 'cancelar_vas_avulso',
},
'mcp_results': [
huge,
{
'tool_name': 'cancelar_vas_avulso',
'ok': True,
'result': {
'status': 'COMPLETED',
'output': {
'results': [
{'subject': 'Tamboro Mensal', 'success': True},
{'subject': 'Paramount+', 'success': False, 'reason': 'service_not_found'},
]
},
},
},
],
'guardrail_llm': llm,
},
)
assert decision.allowed is True
assert decision.metadata['mechanism'] == 'llm_semantic_compact_transaction_context'
assert 'Paramount+' in llm.prompt
assert 'service_not_found' in llm.prompt
assert 'isso mesmo, pode cancelar' in llm.prompt
assert len(llm.prompt) < 30000

View File

@@ -0,0 +1,85 @@
import importlib
import pytest
from app.agents.runtime import AgentRuntimeMixin
class _Runtime(AgentRuntimeMixin):
def _tool_config(self, tool_name):
return None
def _resolve_tool_execution_policy(self, tool_name, arguments=None):
return {"requires": ["subject"]}
def test_set_active_transaction_replaces_previous_terminal_scalar_status():
runtime = _Runtime()
state = {
"transaction_status": "COMPLETED",
"intent": "contas_vas_cancel",
"active_transaction": None,
}
tx = runtime._set_active_transaction(
state,
tool_name="cancelar_vas_avulso",
arguments={"subject": "TIM Fashion Mensal"},
status="AWAITING_CONFIRMATION",
)
assert tx["status"] == "AWAITING_CONFIRMATION"
assert state["active_transaction"]["tool_name"] == "cancelar_vas_avulso"
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
# The next-turn lifecycle normalization must preserve the newly installed
# transaction instead of treating the previous COMPLETED status as current.
runtime._normalize_transaction_lifecycle(state)
assert state["transaction_status"] == "AWAITING_CONFIRMATION"
assert state["active_transaction"]["tool_name"] == "cancelar_vas_avulso"
def test_strategic_preflight_does_not_reresolve_prevalidated_subject(monkeypatch):
mcp = importlib.import_module("contas_mcp.servers.contas_mcp_server.main")
def _unexpected(*args, **kwargs):
raise AssertionError("invoice resolver must not run for a prevalidated strategic subject")
monkeypatch.setattr(mcp.invoice_resolver, "resolve", _unexpected)
result = mcp._preflight_subject(
"tratar_vas_estrategico",
{
"msisdn": "11999999999",
"subject": "Aya Audiobooks Premium",
"_vas_subject_prevalidated": True,
"type": "bundle",
},
)
assert result is None
@pytest.mark.asyncio
async def test_vas_validator_marks_resolved_subject_for_effective_tool(monkeypatch):
mcp = importlib.import_module("contas_mcp.servers.contas_mcp_server.main")
async def _noop_enrich(name, args):
return None
monkeypatch.setattr(mcp, "_enrich_invoice_context", _noop_enrich)
monkeypatch.setattr(mcp, "_vas_entity_catalog", lambda args: [{"name": "Aya Audiobooks Premium"}])
monkeypatch.setattr(mcp, "_resolve_catalog_entity", lambda subject, catalog: ("Aya Audiobooks Premium", []))
monkeypatch.setattr(mcp, "_vas_domain_policy_from_invoice_detail", lambda name, args: ("vas_estrategico", "bundle"))
result = await mcp._validate_vas_subject(
{
"msisdn": "11999999999",
"subject": "Aya Audiobooks Premium",
"target_tool": "cancelar_vas_avulso",
}
)
decision = result["transaction_decision"]
assert decision["target_tool"] == "tratar_vas_estrategico"
assert decision["resolved_arguments"]["subject"] == "Aya Audiobooks Premium"
assert decision["resolved_arguments"]["_vas_subject_prevalidated"] is True
assert decision["resolved_arguments"]["type"] == "bundle"

View File

@@ -0,0 +1,47 @@
import pytest
from agent_framework.guardrails.rails import LoopRail
@pytest.mark.asyncio
async def test_vloop_allows_repeated_reply_while_transaction_awaits_confirmation():
rail = LoopRail()
decision = await rail.evaluate(
"sim",
{
"transaction_status": "AWAITING_CONFIRMATION",
"history_texts": ["sim", "outra fala", "sim"],
},
)
assert decision.allowed is True
assert decision.metadata["mechanism"] == "deterministic_transaction_bypass"
assert decision.metadata["transaction_status"] == "AWAITING_CONFIRMATION"
@pytest.mark.asyncio
async def test_vloop_reads_awaiting_confirmation_from_active_transaction():
rail = LoopRail()
decision = await rail.evaluate(
"sim",
{
"active_transaction": {"status": "AWAITING_CONFIRMATION"},
"history_texts": ["sim", "sim"],
},
)
assert decision.allowed is True
assert decision.metadata["mechanism"] == "deterministic_transaction_bypass"
@pytest.mark.asyncio
async def test_vloop_still_blocks_actual_repetition_outside_transaction_confirmation():
rail = LoopRail()
decision = await rail.evaluate(
"sim",
{
"transaction_status": None,
"history_texts": ["sim", "outra fala", "sim"],
},
)
assert decision.allowed is False
assert decision.reason == "Possível loop conversacional"
assert decision.metadata["repeated"] is True

View File

@@ -0,0 +1,125 @@
import pytest
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
from agent_framework.workflows.models import WorkflowRunResult
import contas_mcp.servers.contas_mcp_server.main as mcp_main
class CapturingWorkflowRuntime:
def __init__(self, result):
self.result = result
self.arun_calls = []
self.aresume_calls = []
async def arun(self, name, payload, version=None, execution_id=None):
self.arun_calls.append({
"name": name,
"payload": dict(payload),
"execution_id": execution_id,
})
return self.result
async def aresume(self, name, execution_id, resume_value, version=None):
self.aresume_calls.append({
"name": name,
"execution_id": execution_id,
"resume_value": resume_value,
})
return self.result
def _result(execution_id="new-exec", name="invoice_explanation", status="COMPLETED"):
return WorkflowRunResult(
execution_id=execution_id,
workflow_name=name,
workflow_version=1,
status=status,
output={},
state={"current_node": None},
trace=[],
)
@pytest.mark.asyncio
async def test_nova_execucao_workflow_ignora_id_residual_da_transacao_anterior(monkeypatch):
runtime = CapturingWorkflowRuntime(_result())
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
out = await mcp_main._run_workflow(
"invoice_explanation",
{
"msisdn": "119",
"workflow_execution_id": "exec-transacao-anterior",
},
)
assert out["execution_id"] == "new-exec"
assert runtime.arun_calls[0]["execution_id"] is None
assert "workflow_execution_id" not in runtime.arun_calls[0]["payload"]
@pytest.mark.asyncio
async def test_novo_cancelamento_composto_ignora_workflow_id_residual(monkeypatch):
runtime = CapturingWorkflowRuntime(
_result(execution_id="cancel-new", name="cancelamento_vas_avulso", status="FAILED")
)
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
out = await mcp_main._run_cancelamento_com_contestacao({
"msisdn": "119",
"subject": "TIM Fashion Mensal",
"workflow_execution_id": "cancel-old",
})
assert out["execution_id"] == "cancel-new"
assert runtime.arun_calls[0]["execution_id"] is None
assert "workflow_execution_id" not in runtime.arun_calls[0]["payload"]
@pytest.mark.asyncio
async def test_resume_e_unico_caminho_que_reutiliza_execution_id(monkeypatch):
runtime = CapturingWorkflowRuntime(
_result(execution_id="exec-paused", name="invoice_explanation", status="COMPLETED")
)
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
await mcp_main._invoke(
"retomar_workflow",
{
"workflow_name": "invoice_explanation",
"execution_id": "exec-paused",
"resposta_usuario": "sim",
},
)
assert runtime.aresume_calls[0]["execution_id"] == "exec-paused"
assert runtime.arun_calls == []
class TransactionRuntime(AgentRuntimeMixin):
pass
def test_nova_transacao_nao_herda_transaction_id_nem_workflow_execution_id_terminal():
runtime = TransactionRuntime()
state = {
"transaction_status": "COMPLETED",
"active_transaction": {
"transaction_id": "tx-old",
"tool_name": "tool-a",
"arguments": {"workflow_execution_id": "wf-old"},
"status": "COMPLETED",
},
"intent": "new-intent",
}
tx = runtime._set_active_transaction(
state,
tool_name="tool-a",
arguments={"subject": "novo", "workflow_execution_id": "wf-old"},
status="AWAITING_CONFIRMATION",
)
assert tx["transaction_id"] != "tx-old"
assert "workflow_execution_id" not in tx["arguments"]
assert state["transaction_status"] == "AWAITING_CONFIRMATION"