344 lines
8.6 KiB
Python
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())
|
|
|