fix(agent): enforce idempotent resume transition
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from models.agent_chat_session import (
|
||||
AgentChatSession,
|
||||
AgentChatSessionStatus,
|
||||
SessionType,
|
||||
)
|
||||
from v1.agent.service import AgentChatService
|
||||
|
||||
|
||||
class FakeAsyncSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self._sessions: dict[UUID, AgentChatSession] = {}
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
self.added.append(obj)
|
||||
if isinstance(obj, AgentChatSession):
|
||||
self._sessions[obj.id] = obj
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
pass
|
||||
|
||||
async def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
async def refresh(self, obj: object) -> None:
|
||||
pass
|
||||
|
||||
async def execute(self, stmt: object) -> None:
|
||||
pass
|
||||
|
||||
async def scalar(self, stmt: object) -> AgentChatSession | None:
|
||||
for session in self._sessions.values():
|
||||
return session
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db() -> FakeAsyncSession:
|
||||
return FakeAsyncSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(fake_db: FakeAsyncSession) -> AgentChatSession:
|
||||
sess = AgentChatSession(
|
||||
id=uuid4(),
|
||||
user_id=uuid4(),
|
||||
session_type=SessionType.CHAT,
|
||||
status=AgentChatSessionStatus.RUNNING,
|
||||
)
|
||||
fake_db.add(sess)
|
||||
return sess
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(fake_db: FakeAsyncSession) -> AgentChatService:
|
||||
return AgentChatService(fake_db, current_user=None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestResumeIdempotency:
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_is_idempotent(
|
||||
self, service: AgentChatService, session: AgentChatSession
|
||||
):
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
await service.set_pending_tool_call(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-1",
|
||||
tool_name="srv.transfer_funds",
|
||||
tool_args={"to": "u2", "amount": 100},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
first = await service.apply_resume_decision(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-1",
|
||||
decision={"decision": "approved"},
|
||||
)
|
||||
second = await service.apply_resume_decision(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-1",
|
||||
decision={"decision": "approved"},
|
||||
)
|
||||
|
||||
assert first.applied is True
|
||||
assert second.applied is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_updates_status_to_approved(
|
||||
self, service: AgentChatService, session: AgentChatSession
|
||||
):
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
await service.set_pending_tool_call(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-2",
|
||||
tool_name="srv.delete_file",
|
||||
tool_args={"file_id": "f1"},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
result = await service.apply_resume_decision(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-2",
|
||||
decision={"decision": "approved"},
|
||||
)
|
||||
|
||||
assert result.applied is True
|
||||
snapshot = await service.get_state_snapshot(session.id)
|
||||
assert snapshot["pending_tool_call"]["status"] == "APPROVED_EXECUTING"
|
||||
assert snapshot["pending_tool_call"]["decision"] == {"decision": "approved"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_updates_status_to_rejected(
|
||||
self, service: AgentChatService, session: AgentChatSession
|
||||
):
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
await service.set_pending_tool_call(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-3",
|
||||
tool_name="srv.transfer_funds",
|
||||
tool_args={"to": "u2", "amount": 100},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
result = await service.apply_resume_decision(
|
||||
session_id=session.id,
|
||||
interrupt_id="int-3",
|
||||
decision={"decision": "rejected"},
|
||||
)
|
||||
|
||||
assert result.applied is True
|
||||
snapshot = await service.get_state_snapshot(session.id)
|
||||
assert snapshot["pending_tool_call"]["status"] == "REJECTED"
|
||||
Reference in New Issue
Block a user