Files
voiceagent/tests/test_deep_agent.py

344 lines
8.6 KiB
Python

import pytest
from unittest.mock import AsyncMock, MagicMock
from voice_assistant.agent.adapter import AgentAdapter
def test_deep_agent_implements_port():
from voice_assistant.agent.deep_agent import DeepAgentsAdapter
assert issubclass(DeepAgentsAdapter, AgentAdapter)
assert callable(getattr(DeepAgentsAdapter, "stream", None))
assert callable(getattr(DeepAgentsAdapter, "aclose", None))
def test_deep_agent_session_config():
from voice_assistant.agent.deep_agent import DeepAgentsAdapter
adapter = object.__new__(DeepAgentsAdapter)
assert adapter._config("session-a") == {
"configurable": {
"thread_id": "session-a",
}
}
assert adapter._config("session-b") == {
"configurable": {
"thread_id": "session-b",
}
}
with pytest.raises(ValueError):
adapter._config("")
def test_deep_agent_cleanup_is_idempotent():
import asyncio
from voice_assistant.agent.deep_agent import DeepAgentsAdapter
adapter = object.__new__(DeepAgentsAdapter)
adapter.model = MagicMock()
adapter.model.aclose = AsyncMock()
adapter._closed = False
async def scenario():
await adapter.aclose()
await adapter.aclose()
asyncio.run(scenario())
adapter.model.aclose.assert_awaited_once()
assert adapter._closed is True
def test_deep_agent_stream_can_be_cancelled():
import asyncio
from voice_assistant.agent.deep_agent import DeepAgentsAdapter
async def scenario():
adapter = object.__new__(DeepAgentsAdapter)
adapter._closed = False
started = asyncio.Event()
cancelled = asyncio.Event()
class FakeAgent:
async def astream(self, *args, **kwargs):
started.set()
try:
yield (
FakeMessage("Primeiro fragmento"),
{"langgraph_node": "model"},
)
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
class FakeMessage:
def __init__(self, content):
self.content = content
self.tool_call_chunks = []
adapter.agent = FakeAgent()
async def consume():
async for _ in adapter.stream(
session_id="session-cancel",
turn_id=1,
text="Teste de cancelamento",
):
pass
task = asyncio.create_task(consume())
await asyncio.wait_for(started.wait(), timeout=2.0)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert cancelled.is_set()
assert task.done()
asyncio.run(scenario())
def test_deep_agent_isolates_session_history():
import asyncio
from deepagents import create_deep_agent
from langchain_core.messages import HumanMessage
from langchain_core.language_models import FakeListChatModel
from langgraph.checkpoint.memory import MemorySaver
async def scenario():
checkpointer = MemorySaver()
class ToolCompatibleFakeChatModel(FakeListChatModel):
def bind_tools(self, tools, *, tool_choice=None, **kwargs):
return self
model = ToolCompatibleFakeChatModel(
responses=[
"Resposta A1",
"Resposta B1",
"Resposta A2",
]
)
agent = create_deep_agent(
model=model,
tools=[],
system_prompt="Responda de forma breve.",
checkpointer=checkpointer,
)
async def ask(session_id, text):
return await agent.ainvoke(
{
"messages": [
HumanMessage(content=text)
]
},
config={
"configurable": {
"thread_id": session_id,
}
},
)
await ask("session-a", "Destino: Japão")
await ask("session-b", "Destino: Canadá")
await ask("session-a", "Qual era meu destino?")
def history(session_id):
snapshot = agent.get_state({
"configurable": {
"thread_id": session_id,
}
})
return snapshot.values.get("messages", [])
history_a = history("session-a")
history_b = history("session-b")
texts_a = [
message.content
for message in history_a
if isinstance(message, HumanMessage)
]
texts_b = [
message.content
for message in history_b
if isinstance(message, HumanMessage)
]
assert texts_a == [
"Destino: Japão",
"Qual era meu destino?",
]
assert texts_b == [
"Destino: Canadá",
]
assert all(
"Canadá" not in text
for text in texts_a
)
assert all(
"Japão" not in text
for text in texts_b
)
asyncio.run(scenario())
def test_deep_agent_checkpoint_survives_cancellation():
import asyncio
from deepagents import create_deep_agent
from langchain_core.language_models import FakeListChatModel
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import MemorySaver
class CancellableFakeModel(FakeListChatModel):
def bind_tools(self, tools, *, tool_choice=None, **kwargs):
return self
async def _agenerate(
self,
messages,
stop=None,
run_manager=None,
**kwargs,
):
last_user_message = next(
(
message
for message in reversed(messages)
if isinstance(message, HumanMessage)
),
None,
)
if (
last_user_message is not None
and last_user_message.content == "Interromper esta resposta"
):
started.set()
await asyncio.Event().wait()
return await super()._agenerate(
messages,
stop=stop,
run_manager=run_manager,
**kwargs,
)
async def scenario():
nonlocal started
started = asyncio.Event()
model = CancellableFakeModel(
responses=[
"Contexto inicial confirmado.",
"Contexto preservado.",
]
)
agent = create_deep_agent(
model=model,
tools=[],
system_prompt="Responda brevemente.",
checkpointer=MemorySaver(),
)
config = {
"configurable": {
"thread_id": "session-cancellation-test",
}
}
async def ask(text):
return await agent.ainvoke(
{
"messages": [
HumanMessage(content=text)
]
},
config=config,
)
# First turn completes successfully.
await ask("Meu destino é Japão")
# Second turn begins but never completes.
task = asyncio.create_task(
ask("Interromper esta resposta")
)
await asyncio.wait_for(
started.wait(),
timeout=2.0,
)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert task.done()
assert task.cancelled()
# The same thread must remain usable.
await asyncio.wait_for(
ask("Qual era meu destino?"),
timeout=5.0,
)
snapshot = await agent.aget_state(config)
messages = snapshot.values.get("messages", [])
human_messages = [
message.content
for message in messages
if isinstance(message, HumanMessage)
]
assert "Meu destino é Japão" in human_messages
assert "Qual era meu destino?" in human_messages
# An interrupted execution must not be recorded
# as a completed assistant response.
assert not any(
getattr(message, "content", None)
== "Resposta interrompida concluída."
for message in messages
)
started = None
asyncio.run(scenario())