Python: [BREAKING] PR2 — Wire context provider pipeline, remove old types, update all consumers (#3850)

* PR2: Wire context provider pipeline and update all internal consumers

- Replace AgentThread with AgentSession across all packages
- Replace ContextProvider with BaseContextProvider across all packages
- Replace context_provider param with context_providers (Sequence)
- Replace thread= with session= in run() signatures
- Replace get_new_thread() with create_session()
- Add get_session(service_session_id) to agent interface
- DurableAgentThread -> DurableAgentSession
- Remove _notify_thread_of_new_messages from WorkflowAgent
- Wire before_run/after_run context provider pipeline in RawAgent
- Auto-inject InMemoryHistoryProvider when no providers configured

* fix: update all tests for context provider pipeline, fix lazy-loaders, remove old test files

* refactor: update all sample files for context provider pipeline (AgentThread→AgentSession, ContextProvider→BaseContextProvider)

* fix: update remaining ag-ui references (client docstring, getting_started sample)

* fix: make get_session service_session_id keyword-only to avoid confusion with session_id

* refactor: rename _RunContext.thread_messages to session_messages

* refactor: remove _threads.py, _memory.py, and old provider files; migrate devui to use plain message lists

* rename: remove _new_ prefix from test files

* refactor: rewrite SlidingWindowChatMessageStore as SlidingWindowHistoryProvider(InMemoryHistoryProvider)

* fix: read full history from session state directly instead of reaching into provider internals

* fix: update stale .pyi stubs, sample imports, and README references for new provider types

* fix: remove stale message_store, _notify_thread_of_new_messages, and session_id.key references in samples

* refactor: merge context_providers and sessions sample folders into sessions, remove aggregate_context_provider

* refactor: UserInfoMemory stores state in session.state instead of instance attributes

* feat: add Pydantic BaseModel support to session state serialization

Pydantic models stored in session.state are now automatically serialized
via model_dump() and restored via model_validate() during to_dict()/from_dict()
round-trips. Models are auto-registered on first serialization; use
register_state_type() for cold-start deserialization.

Also export register_state_type as a public API.

* fix mem0

* Update sample README links and descriptions for session terminology

- Replace 'thread' with 'session' in sample descriptions across all READMEs
- Update file links for renamed samples (mem0_sessions, redis_sessions, etc.)
- Fix Threads section → Sessions section in main samples/README.md
- Update tools, middleware, workflows, durabletask, azure_functions READMEs
- Update architecture diagrams in concepts/tools/README.md
- Update migration guides (autogen, semantic-kernel)

* Fix broken Redis README link to renamed sample

* Fix Mem0 OSS client search: pass scoping params as direct kwargs

AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs,
while AsyncMemoryClient (Platform) expects them in a filters dict.
Adds tests for both client types.

Port of fix from #3844 to new Mem0ContextProvider.

* Fix rebase issues: restore missing _conversation_state.py and checkpoint decode logic

- Add back _conversation_state.py (encode/decode_chat_messages) lost in rebase
- Fix on_checkpoint_restore to decode cache/conversation with decode_chat_messages
- Fix on_checkpoint_restore to use decode_checkpoint_value for pending requests
- Add tests/workflow/__init__.py for relative import support
- Fix test_agent_executor checkpoint selection (checkpoints[1] not superstep)

* Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection

Chat clients that store history server-side by default (OpenAI Responses API,
Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this
during auto-injection and skips InMemoryHistoryProvider unless the user
explicitly sets store=False.

* Fix broken markdown links in azure_ai and redis READMEs

* Fix getting-started samples to use session API instead of removed thread/ContextProvider API

* updates to workflow as agent

* fix group chat import

* Rename Thread→Session throughout, fix service_session_id propagation, remove stale AGUIThread

- Fix: Propagate conversation_id from ChatResponse back to session.service_session_id
  in both streaming and non-streaming paths in _agents.py
- Rename AgentThreadException → AgentSessionException
- Remove stale AGUIThread from ag_ui lazy-loader
- Rename use_service_thread → use_service_session in ag-ui package
- Rename test functions from *_thread_* to *_session_*
- Rename sample files from *_thread* to *_session*
- Update docstrings and comments: thread → session
- Update _mcp.py kwargs filter: add 'session' alongside 'thread'
- Fix ContinuationToken docstring example: thread=thread → session=session
- Fix _clients.py docstring: 'Agent threads' → 'Agent sessions'

* Fix broken markdown links after thread→session file renames

* fix azure ai test
This commit is contained in:
Eduard van Valkenburg
2026-02-12 22:00:32 +01:00
committed by GitHub
Unverified
parent 0c67dbbce5
commit 1e350ea22f
312 changed files with 6669 additions and 11423 deletions
@@ -12,7 +12,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
ChatResponse,
ChatResponseUpdate,
Message,
@@ -433,70 +433,70 @@ async def test_azure_assistants_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_thread_persistence():
"""Test Agent thread persistence across runs with AzureOpenAIAssistantsClient."""
async def test_azure_assistants_agent_session_persistence():
"""Test Agent session persistence across runs with AzureOpenAIAssistantsClient."""
async with Agent(
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
"Remember this number: 42. What number did I just tell you to remember?", session=session
)
assert isinstance(first_response, AgentResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
"What number did I tell you to remember in my previous message?", session=session
)
assert isinstance(second_response, AgentResponse)
assert "42" in second_response.text
# Verify thread has been populated with conversation ID
assert thread.service_thread_id is not None
# Verify session has been populated with conversation ID
assert session.service_session_id is not None
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_existing_thread_id():
"""Test Agent with existing thread ID to continue conversations across agent instances."""
# First, create a conversation and capture the thread ID
existing_thread_id = None
async def test_azure_assistants_agent_existing_session_id():
"""Test Agent with existing session ID to continue conversations across agent instances."""
# First, create a conversation and capture the session ID
existing_session_id = None
async with Agent(
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Start a conversation and get the thread ID
thread = agent.get_new_thread()
response1 = await agent.run("What's the weather in Paris?", thread=thread)
# Start a conversation and get the session ID
session = agent.create_session()
response1 = await agent.run("What's the weather in Paris?", session=session)
# Validate first response
assert isinstance(response1, AgentResponse)
assert response1.text is not None
assert any(word in response1.text.lower() for word in ["weather", "paris"])
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# The session ID is set after the first response
existing_session_id = session.service_session_id
assert existing_session_id is not None
# Now continue with the same thread ID in a new agent instance
# Now continue with the same session ID in a new agent instance
async with Agent(
client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Create a session with the existing ID
session = AgentSession(service_session_id=existing_session_id)
# Ask about the previous conversation
response2 = await agent.run("What was the last city I asked about?", thread=thread)
response2 = await agent.run("What was the last city I asked about?", session=session)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentResponse)
@@ -800,23 +800,23 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_thread_persistence():
"""Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient."""
async def test_azure_openai_chat_client_agent_session_persistence():
"""Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient."""
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First interaction
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
response1 = await agent.run("My name is Alice. Remember this.", session=session)
assert isinstance(response1, AgentResponse)
assert response1.text is not None
# Second interaction - test memory
response2 = await agent.run("What is my name?", thread=thread)
response2 = await agent.run("What is my name?", session=session)
assert isinstance(response2, AgentResponse)
assert response2.text is not None
@@ -825,33 +825,33 @@ async def test_azure_openai_chat_client_agent_thread_persistence():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_existing_thread():
"""Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async def test_azure_openai_chat_client_agent_existing_session():
"""Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
# Reuse the preserved session
second_response = await second_agent.run("What is my name?", session=preserved_session)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
@@ -537,33 +537,33 @@ async def test_integration_client_agent_hosted_code_interpreter_tool():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async def test_integration_client_agent_existing_session():
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
# Reuse the preserved session
second_response = await second_agent.run("What is my hobby?", session=preserved_session)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
+13 -13
View File
@@ -13,7 +13,7 @@ from pytest import fixture
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseChatClient,
ChatMiddlewareLayer,
ChatResponse,
@@ -261,7 +261,7 @@ def chat_client_base(enable_function_calling: bool, max_iterations: int) -> Mock
# region Agents
class MockAgentThread(AgentThread):
class MockAgentSession(AgentSession):
pass
@@ -284,41 +284,41 @@ class MockAgent(SupportsAgentRun):
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
stream: bool = False,
**kwargs: Any,
) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
if stream:
return self._run_stream_impl(messages=messages, thread=thread, **kwargs)
return self._run_impl(messages=messages, thread=thread, **kwargs)
return self._run_stream_impl(messages=messages, session=session, **kwargs)
return self._run_impl(messages=messages, session=session, **kwargs)
async def _run_impl(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
logger.debug(f"Running mock agent, with: {messages=}, {session=}, {kwargs=}")
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Response")])])
async def _run_stream_impl(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
logger.debug(f"Running mock agent stream, with: {messages=}, {session=}, {kwargs=}")
yield AgentResponseUpdate(contents=[Content.from_text("Response")])
def get_new_thread(self) -> AgentThread:
return MockAgentThread()
def create_session(self) -> AgentSession:
return MockAgentSession()
@fixture
def agent_thread() -> AgentThread:
return MockAgentThread()
def agent_session() -> AgentSession:
return MockAgentSession()
@fixture
+193 -186
View File
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
from collections.abc import AsyncIterable, MutableSequence, Sequence
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
@@ -13,13 +13,11 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessageStore,
AgentSession,
BaseContextProvider,
ChatOptions,
ChatResponse,
Content,
Context,
ContextProvider,
FunctionTool,
Message,
SupportsAgentRun,
@@ -28,11 +26,10 @@ from agent_framework import (
)
from agent_framework._agents import _merge_options, _sanitize_agent_name
from agent_framework._mcp import MCPTool
from agent_framework.exceptions import AgentExecutionException, AgentInitializationError
def test_agent_thread_type(agent_thread: AgentThread) -> None:
assert isinstance(agent_thread, AgentThread)
def test_agent_session_type(agent_session: AgentSession) -> None:
assert isinstance(agent_session, AgentSession)
def test_agent_type(agent: SupportsAgentRun) -> None:
@@ -93,38 +90,42 @@ async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse)
assert result.text == "test streaming response another update"
async def test_chat_client_agent_get_new_thread(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = agent.get_new_thread()
session = agent.create_session()
assert isinstance(thread, AgentThread)
assert isinstance(session, AgentSession)
async def test_chat_client_agent_prepare_thread_and_messages(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
async def test_chat_client_agent_prepare_session_and_messages(client: SupportsChatGetResponse) -> None:
from agent_framework._sessions import InMemoryHistoryProvider
agent = Agent(client=client, context_providers=[InMemoryHistoryProvider("memory")])
message = Message(role="user", text="Hello")
thread = AgentThread(message_store=ChatMessageStore(messages=[message]))
session = AgentSession()
session.state["memory"] = {"messages": [message]}
_, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread,
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", text="Test")],
)
result_messages = session_context.get_messages(include_input=True)
assert len(result_messages) == 2
assert result_messages[0] == message
assert result_messages[0].text == "Hello"
assert result_messages[1].text == "Test"
async def test_prepare_thread_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
async def test_prepare_session_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
tool = {"type": "code_interpreter"}
agent = Agent(client=client, tools=[tool])
assert agent.default_options.get("tools") is not None
base_tools = agent.default_options["tools"]
thread = agent.get_new_thread()
session = agent.create_session()
_, prepared_chat_options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread,
_, prepared_chat_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", text="Test")],
)
@@ -135,7 +136,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(client: Support
assert len(agent.default_options["tools"]) == 1
async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None:
mock_response = ChatResponse(
messages=[Message(role="assistant", contents=[Content.from_text("test response")])],
conversation_id="123",
@@ -145,25 +146,24 @@ async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChat
client=chat_client_base,
tools={"type": "code_interpreter"},
)
thread = agent.get_new_thread()
session = agent.get_session(service_session_id="123")
result = await agent.run("Hello", thread=thread)
result = await agent.run("Hello", session=session)
assert result.text == "test response"
assert thread.service_thread_id == "123"
assert session.service_session_id == "123"
async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = agent.get_new_thread()
session = agent.create_session()
result = await agent.run("Hello", thread=thread)
result = await agent.run("Hello", session=session)
assert result.text == "test response"
assert thread.service_thread_id is None
assert thread.message_store is not None
assert session.service_session_id is None
chat_messages: list[Message] = await thread.message_store.list_messages()
chat_messages: list[Message] = session.state.get("memory", {}).get("messages", [])
assert chat_messages is not None
assert len(chat_messages) == 2
@@ -171,12 +171,12 @@ async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetR
assert chat_messages[1].text == "test response"
async def test_chat_client_agent_update_thread_conversation_id_missing(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_update_session_conversation_id_missing(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = AgentThread(service_thread_id="123")
session = agent.get_session(service_session_id="123")
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
# With the session-based API, service_session_id is managed directly on the session
assert session.service_session_id == "123"
async def test_chat_client_agent_default_author_name(client: SupportsChatGetResponse) -> None:
@@ -214,54 +214,41 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
# Mock context provider for testing
class MockContextProvider(ContextProvider):
class MockContextProvider(BaseContextProvider):
def __init__(self, messages: list[Message] | None = None) -> None:
super().__init__(source_id="mock")
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.invoked_thread_id = None
self.before_run_called = False
self.after_run_called = False
self.new_messages: list[Message] = []
self.last_service_session_id: str | None = None
async def thread_created(self, thread_id: str | None) -> None:
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def before_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
self.before_run_called = True
if self.context_messages:
context.extend_messages(self, self.context_messages)
async def invoked(
self,
request_messages: Message | Sequence[Message],
response_messages: Message | Sequence[Message] | None = None,
invoke_exception: Any = None,
**kwargs: Any,
) -> None:
self.invoked_called = True
if isinstance(request_messages, Message):
self.new_messages.append(request_messages)
else:
self.new_messages.extend(request_messages)
if isinstance(response_messages, Message):
self.new_messages.append(response_messages)
else:
self.new_messages.extend(response_messages)
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
self.invoking_called = True
return Context(messages=self.context_messages)
async def after_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
self.after_run_called = True
if session:
self.last_service_session_id = session.service_session_id
if context.response:
self.new_messages.extend(context.input_messages)
self.new_messages.extend(context.response.messages)
async def test_chat_agent_context_providers_model_invoking(client: SupportsChatGetResponse) -> None:
"""Test that context providers' invoking is called during agent run."""
async def test_chat_agent_context_providers_model_before_run(client: SupportsChatGetResponse) -> None:
"""Test that context providers' before_run is called during agent run."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Test context instructions")])
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
await agent.run("Hello")
assert mock_provider.invoking_called
assert mock_provider.before_run_called
async def test_chat_agent_context_providers_thread_created(chat_client_base: SupportsChatGetResponse) -> None:
"""Test that context providers' thread_created is called during agent run."""
async def test_chat_agent_context_providers_after_run(chat_client_base: SupportsChatGetResponse) -> None:
"""Test that context providers' after_run is called during agent run."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
@@ -270,22 +257,23 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Sup
)
]
agent = Agent(client=chat_client_base, context_provider=mock_provider)
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
await agent.run("Hello")
session = agent.get_session(service_session_id="test-thread-id")
await agent.run("Hello", session=session)
assert mock_provider.thread_created_called
assert mock_provider.thread_created_thread_id == "test-thread-id"
assert mock_provider.after_run_called
assert mock_provider.last_service_session_id == "test-thread-id"
async def test_chat_agent_context_providers_messages_adding(client: SupportsChatGetResponse) -> None:
"""Test that context providers' invoked is called during agent run."""
"""Test that context providers' after_run is called during agent run."""
mock_provider = MockContextProvider()
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
await agent.run("Hello")
assert mock_provider.invoked_called
assert mock_provider.after_run_called
# Should be called with both input and response messages
assert len(mock_provider.new_messages) >= 2
@@ -293,12 +281,13 @@ async def test_chat_agent_context_providers_messages_adding(client: SupportsChat
async def test_chat_agent_context_instructions_in_messages(client: SupportsChatGetResponse) -> None:
"""Test that AI context instructions are included in messages."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Context-specific instructions")])
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
# We need to test the _prepare_thread_and_messages method directly
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
# We need to test the _prepare_session_and_messages method directly
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
messages = session_context.get_messages(include_input=True)
# Should have context instructions, and user message
assert len(messages) == 2
@@ -312,11 +301,12 @@ async def test_chat_agent_context_instructions_in_messages(client: SupportsChatG
async def test_chat_agent_no_context_instructions(client: SupportsChatGetResponse) -> None:
"""Test behavior when AI context has no instructions."""
mock_provider = MockContextProvider()
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
messages = session_context.get_messages(include_input=True)
# Should have agent instructions and user message only
assert len(messages) == 1
@@ -327,7 +317,7 @@ async def test_chat_agent_no_context_instructions(client: SupportsChatGetRespons
async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetResponse) -> None:
"""Test that context providers work with run method."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Stream context instructions")])
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
# Collect all stream updates and get final response
stream = agent.run("Hello", stream=True)
@@ -338,14 +328,12 @@ async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetRe
await stream.get_final_response()
# Verify context provider was called
assert mock_provider.invoking_called
# no conversation id is created, so no need to thread_create to be called.
assert not mock_provider.thread_created_called
assert mock_provider.invoked_called
assert mock_provider.before_run_called
assert mock_provider.after_run_called
async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: SupportsChatGetResponse) -> None:
"""Test context providers with service-managed thread."""
async def test_chat_agent_context_providers_with_service_session_id(chat_client_base: SupportsChatGetResponse) -> None:
"""Test context providers with service-managed session."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
@@ -354,14 +342,14 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
)
]
agent = Agent(client=chat_client_base, context_provider=mock_provider)
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
# Use existing service-managed thread
thread = agent.get_new_thread(service_thread_id="existing-thread-id")
await agent.run("Hello", thread=thread)
# Use existing service-managed session
session = agent.get_session(service_session_id="existing-thread-id")
await agent.run("Hello", session=session)
# invoked should be called with the service thread ID from response
assert mock_provider.invoked_called
# after_run should be called
assert mock_provider.after_run_called
# Tests for as_tool method
@@ -562,16 +550,16 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse)
pass
async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> None:
"""Verify tool execution receives 'thread' inside **kwargs when function is called by client."""
async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None:
"""Verify tool execution receives 'session' inside **kwargs when function is called by client."""
captured: dict[str, Any] = {}
@tool(name="echo_thread_info", approval_mode="never_require")
def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
thread = kwargs.get("thread")
captured["has_thread"] = thread is not None
captured["has_message_store"] = thread.message_store is not None if isinstance(thread, AgentThread) else False
@tool(name="echo_session_info", approval_mode="never_require")
def echo_session_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
session = kwargs.get("session")
captured["has_session"] = session is not None
captured["has_state"] = session.state is not None if isinstance(session, AgentSession) else False
return f"echo: {text}"
# Make the base client emit a function call for our tool
@@ -580,21 +568,21 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')
Content.from_function_call(call_id="1", name="echo_session_info", arguments='{"text": "hello"}')
],
)
),
ChatResponse(messages=Message(role="assistant", text="done")),
]
agent = Agent(client=chat_client_base, tools=[echo_thread_info], chat_message_store_factory=ChatMessageStore)
thread = agent.get_new_thread()
agent = Agent(client=chat_client_base, tools=[echo_session_info])
session = agent.create_session()
result = await agent.run("hello", thread=thread, options={"additional_function_arguments": {"thread": thread}})
result = await agent.run("hello", session=session, options={"additional_function_arguments": {"session": session}})
assert result.text == "done"
assert captured.get("has_thread") is True
assert captured.get("has_message_store") is True
assert captured.get("has_session") is True
assert captured.get("has_state") is True
async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None:
@@ -801,73 +789,67 @@ def test_sanitize_agent_name_replaces_invalid_chars():
# endregion
# region Test SupportsAgentRun.get_new_thread and deserialize_thread
# region Test SupportsAgentRun.create_session
@pytest.mark.asyncio
async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test that get_new_thread returns a new AgentThread."""
async def test_agent_create_session(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test that create_session returns a new AgentSession."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
thread = agent.get_new_thread()
session = agent.create_session()
assert thread is not None
assert isinstance(thread, AgentThread)
assert session is not None
assert isinstance(session, AgentSession)
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_context_provider(
async def test_agent_create_session_with_context_providers(
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes context_provider to the thread."""
"""Test that create_session works when context_providers are set on the agent."""
class TestContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context()
class TestContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="test")
provider = TestContextProvider()
agent = Agent(client=chat_client_base, tools=[tool_tool], context_provider=provider)
agent = Agent(client=chat_client_base, tools=[tool_tool], context_providers=[provider])
thread = agent.get_new_thread()
session = agent.create_session()
assert thread is not None
assert thread.context_provider is provider
assert session is not None
assert agent.context_providers[0] is provider
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_service_thread_id(
async def test_agent_get_session_with_service_session_id(
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes kwargs like service_thread_id to the thread."""
"""Test that get_session creates a session with service_session_id."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
thread = agent.get_new_thread(service_thread_id="test-thread-123")
session = agent.get_session(service_session_id="test-thread-123")
assert thread is not None
assert thread.service_thread_id == "test-thread-123"
assert session is not None
assert session.service_session_id == "test-thread-123"
@pytest.mark.asyncio
async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test deserialize_thread restores a thread from serialized state."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
# Create serialized thread state with messages
def test_agent_session_from_dict(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test AgentSession.from_dict restores a session from serialized state."""
# Create serialized session state
serialized_state = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
"type": "session",
"session_id": "test-session",
"service_session_id": None,
"state": {},
}
thread = await agent.deserialize_thread(serialized_state)
session = AgentSession.from_dict(serialized_state)
assert thread is not None
assert isinstance(thread, AgentThread)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Hello"
assert session is not None
assert isinstance(session, AgentSession)
assert session.session_id == "test-session"
# endregion
@@ -876,20 +858,6 @@ async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetRespons
# region Test Agent initialization edge cases
@pytest.mark.asyncio
async def test_chat_agent_raises_with_both_conversation_id_and_store():
"""Test Agent raises error with both conversation_id and chat_message_store_factory."""
mock_client = MagicMock()
mock_store_factory = MagicMock()
with pytest.raises(AgentInitializationError, match="Cannot specify both"):
Agent(
client=mock_client,
default_options={"conversation_id": "test_id"},
chat_message_store_factory=mock_store_factory,
)
def test_chat_agent_calls_update_agent_name_on_client():
"""Test that Agent calls _update_agent_name_and_description on client if available."""
mock_client = MagicMock()
@@ -914,19 +882,22 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_c
"""A tool provided by context."""
return text
class ToolContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context(tools=[context_tool])
class ToolContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="tool-context")
async def before_run(self, *, agent, session, context, state):
context.extend_tools("tool-context", [context_tool])
provider = ToolContextProvider()
agent = Agent(client=chat_client_base, context_provider=provider)
agent = Agent(client=chat_client_base, context_providers=[provider])
# Agent starts with empty tools list
assert agent.default_options.get("tools") == []
# Run the agent and verify context tools are added
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
# The context tools should now be in the options
@@ -940,40 +911,76 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none
):
"""Test that context provider instructions are used when agent has no default instructions."""
class InstructionContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context(instructions="Context-provided instructions")
class InstructionContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="instruction-context")
async def before_run(self, *, agent, session, context, state):
context.extend_instructions("instruction-context", "Context-provided instructions")
provider = InstructionContextProvider()
agent = Agent(client=chat_client_base, context_provider=provider)
agent = Agent(client=chat_client_base, context_providers=[provider])
# Verify agent has no default instructions
assert agent.default_options.get("instructions") is None
# Run the agent and verify context instructions are available
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
# The context instructions should now be in the options
assert options.get("instructions") == "Context-provided instructions"
@pytest.mark.asyncio
async def test_chat_agent_raises_on_conversation_id_mismatch(chat_client_base: SupportsChatGetResponse):
"""Test that Agent raises when thread and agent have different conversation IDs."""
agent = Agent(
client=chat_client_base,
default_options={"conversation_id": "agent-conversation-id"},
)
# region STORES_BY_DEFAULT tests
# Create a thread with a different service_thread_id
thread = AgentThread(service_thread_id="different-thread-id")
with pytest.raises(AgentExecutionException, match="conversation_id set on the agent is different"):
await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread, input_messages=[Message(role="user", text="Hello")]
)
async def test_stores_by_default_skips_inmemory_injection(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=True should not auto-inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
# Simulate a client that stores by default
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session)
# No InMemoryHistoryProvider should have been injected
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_stores_by_default_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=False (default) should auto-inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session)
# InMemoryHistoryProvider should have been injected
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_stores_by_default_with_store_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=True but store=False should still inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session, options={"store": False})
# User explicitly disabled server storage, so InMemoryHistoryProvider should be injected
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
# endregion
# endregion
@@ -168,8 +168,8 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: Suppo
agent = Agent(client=chat_client_base, tools=[ai_func])
async def handler(request: web.Request) -> web.Response:
thread = agent.get_new_thread()
result = await agent.run("Fix issue", thread=thread)
session = agent.create_session()
result = await agent.run("Fix issue", session=session)
return web.Response(text=result.text or "")
app = web.Application()
@@ -230,8 +230,8 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Sup
async def init_app() -> web.Application:
async def handler(request: web.Request) -> web.Response:
thread = agent.get_new_thread()
result = await agent.run("Fix issue", thread=thread)
session = agent.create_session()
result = await agent.run("Fix issue", session=session)
return web.Response(text=result.text or "")
app = web.Application()
+3 -5
View File
@@ -25,8 +25,8 @@ from agent_framework._mcp import (
_get_input_model_from_mcp_tool,
_normalize_mcp_name,
_parse_content_from_mcp,
_parse_tool_result_from_mcp,
_parse_message_from_mcp,
_parse_tool_result_from_mcp,
_prepare_content_for_mcp,
_prepare_message_for_mcp,
logger,
@@ -97,9 +97,7 @@ def test_parse_tool_result_from_mcp():
def test_parse_tool_result_from_mcp_single_text():
"""Test conversion from MCP tool result with a single text item."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Simple result")]
)
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")])
result = _parse_tool_result_from_mcp(mcp_result)
# Single text item returns just the text
@@ -2590,7 +2588,7 @@ async def test_mcp_tool_filters_framework_kwargs():
chat_options={"some": "option"}, # Should be filtered
tools=[Mock()], # Should be filtered
tool_choice="auto", # Should be filtered
thread=Mock(), # Should be filtered
session=Mock(), # Should be filtered
conversation_id="conv-123", # Should be filtered
options={"metadata": "value"}, # Should be filtered
)
@@ -1,136 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import MutableSequence
from typing import Any
from agent_framework import Message
from agent_framework._memory import Context, ContextProvider
class MockContextProvider(ContextProvider):
"""Mock ContextProvider for testing."""
def __init__(self, messages: list[Message] | None = None) -> None:
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.new_messages = None
self.model_invoking_messages = None
async def thread_created(self, thread_id: str | None) -> None:
"""Track thread_created calls."""
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def invoked(
self,
request_messages: Any,
response_messages: Any | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
"""Track invoked calls."""
self.invoked_called = True
self.new_messages = request_messages
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
"""Track invoking calls and return context."""
self.invoking_called = True
self.model_invoking_messages = messages
context = Context()
context.messages = self.context_messages
return context
class MinimalContextProvider(ContextProvider):
"""Minimal ContextProvider that only implements the required abstract method.
Used to test the base class default implementations of thread_created,
invoked, __aenter__, and __aexit__.
"""
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
"""Return empty context."""
return Context()
class TestContext:
"""Tests for Context class."""
def test_context_default_values(self) -> None:
"""Test Context has correct default values."""
context = Context()
assert context.instructions is None
assert context.messages == []
assert context.tools == []
def test_context_with_values(self) -> None:
"""Test Context can be initialized with values."""
messages = [Message(role="user", text="Test message")]
context = Context(instructions="Test instructions", messages=messages)
assert context.instructions == "Test instructions"
assert len(context.messages) == 1
assert context.messages[0].text == "Test message"
class TestContextProvider:
"""Tests for ContextProvider class."""
async def test_thread_created(self) -> None:
"""Test thread_created is called."""
provider = MockContextProvider()
await provider.thread_created("test-thread-id")
assert provider.thread_created_called
assert provider.thread_created_thread_id == "test-thread-id"
async def test_invoked(self) -> None:
"""Test invoked is called."""
provider = MockContextProvider()
message = Message(role="user", text="Test message")
await provider.invoked(message)
assert provider.invoked_called
assert provider.new_messages == message
async def test_invoking(self) -> None:
"""Test invoking is called and returns context."""
provider = MockContextProvider(messages=[Message(role="user", text="Context message")])
message = Message(role="user", text="Test message")
context = await provider.invoking(message)
assert provider.invoking_called
assert provider.model_invoking_messages == message
assert context.messages is not None
assert len(context.messages) == 1
assert context.messages[0].text == "Context message"
async def test_base_thread_created_does_nothing(self) -> None:
"""Test that base ContextProvider.thread_created does nothing by default."""
provider = MinimalContextProvider()
await provider.thread_created("some-thread-id")
await provider.thread_created(None)
async def test_base_invoked_does_nothing(self) -> None:
"""Test that base ContextProvider.invoked does nothing by default."""
provider = MinimalContextProvider()
message = Message(role="user", text="Test")
await provider.invoked(message)
await provider.invoked(message, response_messages=message)
await provider.invoked(message, invoke_exception=Exception("test"))
async def test_base_aenter_returns_self(self) -> None:
"""Test that base ContextProvider.__aenter__ returns self."""
provider = MinimalContextProvider()
async with provider as p:
assert p is provider
async def test_base_aexit_does_nothing(self) -> None:
"""Test that base ContextProvider.__aexit__ handles exceptions gracefully."""
provider = MinimalContextProvider()
await provider.__aexit__(None, None, None)
try:
raise ValueError("test error")
except ValueError:
exc_info = sys.exc_info()
await provider.__aexit__(exc_info[0], exc_info[1], exc_info[2])
@@ -56,17 +56,17 @@ class TestAgentContext:
assert context.stream is True
assert context.metadata == metadata
def test_init_with_thread(self, mock_agent: SupportsAgentRun) -> None:
"""Test AgentContext initialization with thread parameter."""
from agent_framework import AgentThread
def test_init_with_session(self, mock_agent: SupportsAgentRun) -> None:
"""Test AgentContext initialization with session parameter."""
from agent_framework import AgentSession
messages = [Message(role="user", text="test")]
thread = AgentThread()
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
session = AgentSession()
context = AgentContext(agent=mock_agent, messages=messages, session=session)
assert context.agent is mock_agent
assert context.messages == messages
assert context.thread is thread
assert context.session is session
assert context.stream is False
assert context.metadata == {}
@@ -356,23 +356,23 @@ class TestAgentMiddlewarePipeline:
assert updates[1].text == "chunk2"
assert execution_order == ["handler_start", "handler_end"]
async def test_execute_with_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution properly passes thread to middleware."""
from agent_framework import AgentThread
async def test_execute_with_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution properly passes session to middleware."""
from agent_framework import AgentSession
captured_thread = None
captured_session = None
class ThreadCapturingMiddleware(AgentMiddleware):
class SessionCapturingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
nonlocal captured_thread
captured_thread = context.thread
nonlocal captured_session
captured_session = context.session
await call_next()
middleware = ThreadCapturingMiddleware()
middleware = SessionCapturingMiddleware()
pipeline = AgentMiddlewarePipeline(middleware)
messages = [Message(role="user", text="test")]
thread = AgentThread()
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
session = AgentSession()
context = AgentContext(agent=mock_agent, messages=messages, session=session)
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
@@ -381,22 +381,22 @@ class TestAgentMiddlewarePipeline:
result = await pipeline.execute(context, final_handler)
assert result == expected_response
assert captured_thread is thread
assert captured_session is session
async def test_execute_with_no_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution when no thread is provided."""
captured_thread = "not_none" # Use string to distinguish from None
async def test_execute_with_no_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution when no session is provided."""
captured_session = "not_none" # Use string to distinguish from None
class ThreadCapturingMiddleware(AgentMiddleware):
class SessionCapturingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
nonlocal captured_thread
captured_thread = context.thread
nonlocal captured_session
captured_session = context.session
await call_next()
middleware = ThreadCapturingMiddleware()
middleware = SessionCapturingMiddleware()
pipeline = AgentMiddlewarePipeline(middleware)
messages = [Message(role="user", text="test")]
context = AgentContext(agent=mock_agent, messages=messages, thread=None)
context = AgentContext(agent=mock_agent, messages=messages, session=None)
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
@@ -405,7 +405,7 @@ class TestAgentMiddlewarePipeline:
result = await pipeline.execute(context, final_handler)
assert result == expected_response
assert captured_thread is None
assert captured_session is None
class TestFunctionMiddlewarePipeline:
@@ -1405,19 +1405,19 @@ class TestMiddlewareDecoratorLogic:
assert test_function_middleware._middleware_type == MiddlewareType.FUNCTION # type: ignore[attr-defined]
class TestChatAgentThreadBehavior:
"""Test cases for thread behavior in AgentContext across multiple runs."""
class TestChatAgentSessionBehavior:
"""Test cases for session behavior in AgentContext across multiple runs."""
async def test_agent_context_thread_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
"""Test that AgentContext.thread property behaves correctly across multiple agent runs."""
async def test_agent_context_session_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
"""Test that AgentContext.session property behaves correctly across multiple agent runs."""
thread_states: list[dict[str, Any]] = []
class ThreadTrackingMiddleware(AgentMiddleware):
class SessionTrackingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
# Capture state before next() call
thread_messages = []
if context.thread and context.thread.message_store:
thread_messages = await context.thread.message_store.list_messages()
if context.session and context.session.state.get("memory"):
thread_messages = context.session.state.get("memory", {}).get("messages", [])
before_state = {
"before_next": True,
@@ -1432,8 +1432,8 @@ class TestChatAgentThreadBehavior:
# Capture state after next() call
thread_messages_after = []
if context.thread and context.thread.message_store:
thread_messages_after = await context.thread.message_store.list_messages()
if context.session and context.session.state.get("memory"):
thread_messages_after = context.session.state.get("memory", {}).get("messages", [])
after_state = {
"before_next": False,
@@ -1444,19 +1444,16 @@ class TestChatAgentThreadBehavior:
}
thread_states.append(after_state)
# Import the ChatMessageStore to configure the agent with a message store factory
from agent_framework import ChatMessageStore
# Create Agent with session tracking middleware
middleware = SessionTrackingMiddleware()
agent = Agent(client=client, middleware=[middleware])
# Create Agent with thread tracking middleware and a message store factory
middleware = ThreadTrackingMiddleware()
agent = Agent(client=client, middleware=[middleware], chat_message_store_factory=ChatMessageStore)
# Create a thread that will persist messages between runs
thread = agent.get_new_thread()
# Create a session that will persist messages between runs
session = agent.create_session()
# First run
first_messages = [Message(role="user", text="first message")]
first_response = await agent.run(first_messages, thread=thread)
first_response = await agent.run(first_messages, session=session)
# Verify first response
assert first_response is not None
@@ -1464,7 +1461,7 @@ class TestChatAgentThreadBehavior:
# Second run - use the same thread
second_messages = [Message(role="user", text="second message")]
second_response = await agent.run(second_messages, thread=thread)
second_response = await agent.run(second_messages, session=session)
# Verify second response
assert second_response is not None
@@ -441,19 +441,19 @@ def mock_chat_agent():
self.description = "Test agent description"
self.default_options: dict[str, Any] = {"model_id": "TestModel"}
def run(self, messages=None, *, thread=None, stream=False, **kwargs):
def run(self, messages=None, *, session=None, stream=False, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(
messages=[Message("assistant", ["Agent response"])],
usage_details=UsageDetails(input_token_count=15, output_token_count=25),
response_id="test_response_id",
)
async def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
async def _stream():
@@ -1572,12 +1572,12 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
messages=None,
*,
stream: bool = False,
thread=None,
session=None,
**kwargs,
):
if stream:
return ResponseStream(
self._run_stream(messages=messages, thread=thread),
self._run_stream(messages=messages, session=session),
finalizer=lambda x: AgentResponse.from_updates(x),
)
return AgentResponse(messages=[Message("assistant", ["Test response"])])
@@ -1586,7 +1586,7 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
self,
messages=None,
*,
thread=None,
session=None,
**kwargs,
):
from agent_framework import AgentResponseUpdate
@@ -1635,7 +1635,7 @@ async def test_agent_observability_with_exception(span_exporter: InMemorySpanExp
def default_options(self):
return self._default_options
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
raise RuntimeError("Agent failed")
class FailingAgent(AgentTelemetryLayer, _FailingAgent):
@@ -1685,15 +1685,15 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[Message("assistant", ["Test"])])
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text("World")], role="assistant")
@@ -1822,15 +1822,15 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[])
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("Starting")], role="assistant")
raise RuntimeError("Stream failed")
@@ -1919,7 +1919,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
def default_options(self):
return self._default_options
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
if stream:
return ResponseStream(
self._run_stream(messages=messages, **kwargs),
@@ -1927,7 +1927,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
)
return AgentResponse(messages=[])
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
async def _run_stream(self, messages=None, *, session=None, **kwargs):
from agent_framework import AgentResponseUpdate
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
@@ -1974,15 +1974,15 @@ async def test_agent_streaming_when_disabled(span_exporter: InMemorySpanExporter
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream(messages=messages, **kwargs)
return self._run(messages=messages, **kwargs)
async def _run(self, messages=None, *, thread=None, **kwargs):
async def _run(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[])
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
async def _run_stream(self, messages=None, *, session=None, **kwargs):
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
class TestAgent(AgentTelemetryLayer, _TestAgent):
@@ -28,6 +28,12 @@ class SecretSettings(TypedDict, total=False):
username: str | None
class ExclusiveSettings(TypedDict, total=False):
source_a: str | None
source_b: str | None
other: str | None
class TestLoadSettingsBasic:
"""Test basic load_settings functionality."""
@@ -236,3 +242,89 @@ class TestOverrideTypeValidation:
assert isinstance(settings["api_key"], SecretString)
assert settings["api_key"] == "plain-string"
class TestMutuallyExclusive:
"""Test mutually exclusive field validation via tuple entries in required_fields."""
def test_exactly_one_set_passes(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="value-a",
)
assert settings["source_a"] == "value-a"
assert settings["source_b"] is None
def test_none_set_raises(self) -> None:
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError, match="none was set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
)
def test_both_set_raises(self) -> None:
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError, match="multiple were set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
source_b="b",
)
def test_env_var_counts_as_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
)
assert settings["source_b"] == "env-b"
def test_env_var_and_override_both_set_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agent_framework.exceptions import SettingNotFoundError
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
with pytest.raises(SettingNotFoundError, match="multiple were set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
)
def test_other_fields_unaffected(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
other="extra",
)
assert settings["source_a"] == "a"
assert settings["other"] == "extra"
def test_mixed_required_and_exclusive(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=["other", ("source_a", "source_b")],
source_b="b",
other="required-val",
)
assert settings["other"] == "required-val"
assert settings["source_b"] == "b"
assert settings["source_a"] is None
@@ -1,600 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import Any
import pytest
from agent_framework import AgentThread, ChatMessageStore, Message
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
from agent_framework.exceptions import AgentThreadException
class MockChatMessageStore:
"""Mock implementation of ChatMessageStoreProtocol for testing."""
def __init__(self, messages: list[Message] | None = None) -> None:
self._messages = messages or []
self._serialize_calls = 0
self._deserialize_calls = 0
async def list_messages(self) -> list[Message]:
return self._messages
async def add_messages(self, messages: Sequence[Message]) -> None:
self._messages.extend(messages)
async def serialize(self, **kwargs: Any) -> Any:
self._serialize_calls += 1
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
self._deserialize_calls += 1
if serialized_store_state and "messages" in serialized_store_state:
self._messages = serialized_store_state["messages"]
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "MockChatMessageStore":
instance = cls()
await instance.update_from_state(serialized_store_state, **kwargs)
return instance
@pytest.fixture
def sample_messages() -> list[Message]:
"""Fixture providing sample chat messages for testing."""
return [
Message(role="user", text="Hello", message_id="msg1"),
Message(role="assistant", text="Hi there!", message_id="msg2"),
Message(role="user", text="How are you?", message_id="msg3"),
]
@pytest.fixture
def sample_message() -> Message:
"""Fixture providing a single sample chat message for testing."""
return Message(role="user", text="Test message", message_id="test1")
class TestAgentThread:
"""Test cases for AgentThread class."""
def test_init_with_no_parameters(self) -> None:
"""Test AgentThread initialization with no parameters."""
thread = AgentThread()
assert thread.service_thread_id is None
assert thread.message_store is None
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThread initialization with service_thread_id."""
service_thread_id = "test-conversation-123"
thread = AgentThread(service_thread_id=service_thread_id)
assert thread.service_thread_id == service_thread_id
assert thread.message_store is None
def test_init_with_message_store(self) -> None:
"""Test AgentThread initialization with message_store."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
assert thread.service_thread_id is None
assert thread.message_store is store
def test_service_thread_id_property_setter(self) -> None:
"""Test service_thread_id property setter."""
thread = AgentThread()
service_thread_id = "test-conversation-456"
thread.service_thread_id = service_thread_id
assert thread.service_thread_id == service_thread_id
def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None:
"""Test that setting service_thread_id when message_store exists raises AgentThreadException."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.service_thread_id = "test-conversation-789"
def test_service_thread_id_setter_with_none_values(self) -> None:
"""Test service_thread_id setter with None values does nothing."""
thread = AgentThread()
thread.service_thread_id = None # Should not raise error
assert thread.service_thread_id is None
def test_message_store_property_setter(self) -> None:
"""Test message_store property setter."""
thread = AgentThread()
store = ChatMessageStore()
thread.message_store = store
assert thread.message_store is store
def test_message_store_setter_with_existing_service_thread_id_raises_error(self) -> None:
"""Test that setting message_store when service_thread_id exists raises AgentThreadException."""
service_thread_id = "test-conversation-999"
thread = AgentThread(service_thread_id=service_thread_id)
store = ChatMessageStore()
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.message_store = store
def test_message_store_setter_with_none_values(self) -> None:
"""Test message_store setter with None values does nothing."""
thread = AgentThread()
thread.message_store = None # Should not raise error
assert thread.message_store is None
async def test_get_messages_with_message_store(self, sample_messages: list[Message]) -> None:
"""Test get_messages when message_store is set."""
store = ChatMessageStore(sample_messages)
thread = AgentThread(message_store=store)
assert thread.message_store is not None
messages: list[Message] = await thread.message_store.list_messages()
assert messages is not None
assert len(messages) == 3
assert messages[0].text == "Hello"
assert messages[1].text == "Hi there!"
assert messages[2].text == "How are you?"
async def test_get_messages_with_no_message_store(self) -> None:
"""Test get_messages when no message_store is set."""
thread = AgentThread()
assert thread.message_store is None
async def test_on_new_messages_with_service_thread_id(self, sample_message: Message) -> None:
"""Test _on_new_messages when service_thread_id is set (should do nothing)."""
thread = AgentThread(service_thread_id="test-conv")
await thread.on_new_messages(sample_message)
# Should not create a message store
assert thread.message_store is None
async def test_on_new_messages_single_message_creates_store(self, sample_message: Message) -> None:
"""Test _on_new_messages with single message creates ChatMessageStore."""
thread = AgentThread()
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
messages = await thread.message_store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Test message"
async def test_on_new_messages_multiple_messages(self, sample_messages: list[Message]) -> None:
"""Test _on_new_messages with multiple messages."""
thread = AgentThread()
await thread.on_new_messages(sample_messages)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 3
async def test_on_new_messages_with_existing_store(self, sample_message: Message) -> None:
"""Test _on_new_messages adds to existing message store."""
initial_messages = [Message(role="user", text="Initial", message_id="init1")]
store = ChatMessageStore(initial_messages)
thread = AgentThread(message_store=store)
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 2
assert messages[0].text == "Initial"
assert messages[1].text == "Test message"
async def test_deserialize_with_service_thread_id(self) -> None:
"""Test _deserialize with service_thread_id."""
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id == "test-conv-123"
assert thread.message_store is None
async def test_deserialize_with_store_state(self, sample_messages: list[Message]) -> None:
"""Test _deserialize with chat_message_store_state."""
store_state = {"messages": sample_messages}
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
async def test_deserialize_with_no_state(self) -> None:
"""Test _deserialize with no state."""
thread = AgentThread()
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
await thread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is None
async def test_deserialize_with_existing_store(self) -> None:
"""Test _deserialize with existing message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
serialized_data: dict[str, Any] = {
"service_thread_id": None,
"chat_message_store_state": {"messages": [Message(role="user", text="test")]},
}
await thread.update_from_thread_state(serialized_data)
assert store._messages
assert store._messages[0].text == "test"
async def test_serialize_with_service_thread_id(self) -> None:
"""Test serialize with service_thread_id."""
thread = AgentThread(service_thread_id="test-conv-456")
result = await thread.serialize()
assert result["service_thread_id"] == "test-conv-456"
assert result["chat_message_store_state"] is None
async def test_serialize_with_message_store(self) -> None:
"""Test serialize with message_store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is not None
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_with_no_state(self) -> None:
"""Test serialize with no state."""
thread = AgentThread()
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is None
async def test_serialize_with_kwargs(self) -> None:
"""Test serialize passes kwargs to message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
await thread.serialize(custom_param="test_value")
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_round_trip_messages(self, sample_messages: list[Message]) -> None:
"""Test a roundtrip of the serialization."""
store = ChatMessageStore(sample_messages)
thread = AgentThread(message_store=store)
new_thread = await AgentThread.deserialize(await thread.serialize())
assert new_thread.message_store is not None
new_messages = await new_thread.message_store.list_messages()
assert len(new_messages) == len(sample_messages)
assert {new.text for new in new_messages} == {orig.text for orig in sample_messages}
async def test_serialize_round_trip_thread_id(self) -> None:
"""Test a roundtrip of the serialization."""
thread = AgentThread(service_thread_id="test-1234")
new_thread = await AgentThread.deserialize(await thread.serialize())
assert new_thread.message_store is None
assert new_thread.service_thread_id == "test-1234"
class TestChatMessageList:
"""Test cases for ChatMessageStore class."""
def test_init_empty(self) -> None:
"""Test ChatMessageStore initialization with no messages."""
store = ChatMessageStore()
assert len(store.messages) == 0
def test_init_with_messages(self, sample_messages: list[Message]) -> None:
"""Test ChatMessageStore initialization with messages."""
store = ChatMessageStore(sample_messages)
assert len(store.messages) == 3
async def test_add_messages(self, sample_messages: list[Message]) -> None:
"""Test adding messages to the store."""
store = ChatMessageStore()
await store.add_messages(sample_messages)
assert len(store.messages) == 3
messages = await store.list_messages()
assert messages[0].text == "Hello"
async def test_get_messages(self, sample_messages: list[Message]) -> None:
"""Test getting messages from the store."""
store = ChatMessageStore(sample_messages)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].message_id == "msg1"
async def test_serialize_state(self, sample_messages: list[Message]) -> None:
"""Test serializing store state."""
store = ChatMessageStore(sample_messages)
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 3
async def test_serialize_state_empty(self) -> None:
"""Test serializing empty store state."""
store = ChatMessageStore()
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 0
async def test_deserialize_state(self, sample_messages: list[Message]) -> None:
"""Test deserializing store state."""
store = ChatMessageStore()
state_data = {"messages": sample_messages}
await store.update_from_state(state_data)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].text == "Hello"
async def test_deserialize_state_none(self) -> None:
"""Test deserializing None state."""
store = ChatMessageStore()
await store.update_from_state(None)
assert len(store.messages) == 0
async def test_deserialize_state_empty(self) -> None:
"""Test deserializing empty state."""
store = ChatMessageStore()
await store.update_from_state({})
assert len(store.messages) == 0
class TestStoreState:
"""Test cases for ChatMessageStoreState class."""
def test_init(self, sample_messages: list[Message]) -> None:
"""Test ChatMessageStoreState initialization."""
state = ChatMessageStoreState(messages=sample_messages)
assert len(state.messages) == 3
assert state.messages[0].text == "Hello"
def test_init_empty(self) -> None:
"""Test ChatMessageStoreState initialization with empty messages."""
state = ChatMessageStoreState(messages=[])
assert len(state.messages) == 0
def test_init_none(self) -> None:
"""Test ChatMessageStoreState initialization with None messages."""
state = ChatMessageStoreState(messages=None)
assert len(state.messages) == 0
def test_init_no_messages_arg(self) -> None:
"""Test ChatMessageStoreState initialization without messages argument."""
state = ChatMessageStoreState()
assert len(state.messages) == 0
class TestThreadState:
"""Test cases for AgentThreadState class."""
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThreadState initialization with service_thread_id."""
state = AgentThreadState(service_thread_id="test-conv-123")
assert state.service_thread_id == "test-conv-123"
assert state.chat_message_store_state is None
def test_init_with_chat_message_store_state(self) -> None:
"""Test AgentThreadState initialization with chat_message_store_state."""
store_data: dict[str, Any] = {"messages": []}
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
assert state.service_thread_id is None
assert state.chat_message_store_state.messages == []
def test_init_with_both(self) -> None:
"""Test AgentThreadState initialization with both parameters."""
store_data: dict[str, Any] = {"messages": []}
with pytest.raises(AgentThreadException):
AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data)
def test_init_defaults(self) -> None:
"""Test AgentThreadState initialization with defaults."""
state = AgentThreadState()
assert state.service_thread_id is None
assert state.chat_message_store_state is None
def test_init_with_chat_message_store_state_no_messages(self) -> None:
"""Test AgentThreadState initialization with chat_message_store_state without messages field.
This tests the scenario where a custom ChatMessageStore (like RedisChatMessageStore)
serializes its state without a 'messages' field, containing only configuration data
like thread_id, redis_url, etc.
"""
store_data: dict[str, Any] = {
"type": "redis_store_state",
"thread_id": "test_thread_123",
"redis_url": "redis://localhost:6379",
"key_prefix": "chat_messages",
}
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
assert state.service_thread_id is None
assert state.chat_message_store_state is not None
assert state.chat_message_store_state.messages == []
def test_init_with_chat_message_store_state_object(self) -> None:
"""Test AgentThreadState initialization with ChatMessageStoreState object."""
store_state = ChatMessageStoreState(messages=[Message(role="user", text="test")])
state = AgentThreadState(chat_message_store_state=store_state)
assert state.service_thread_id is None
assert state.chat_message_store_state is store_state
assert len(state.chat_message_store_state.messages) == 1
def test_init_with_invalid_chat_message_store_state_type(self) -> None:
"""Test AgentThreadState initialization with invalid chat_message_store_state type."""
with pytest.raises(TypeError, match="Could not parse ChatMessageStoreState"):
AgentThreadState(chat_message_store_state="invalid_type") # type: ignore[arg-type]
class TestChatMessageStoreStateEdgeCases:
"""Additional edge case tests for ChatMessageStoreState."""
def test_init_with_invalid_messages_type(self) -> None:
"""Test ChatMessageStoreState initialization with invalid messages type."""
with pytest.raises(TypeError, match="Messages should be a list"):
ChatMessageStoreState(messages="invalid") # type: ignore[arg-type]
def test_init_with_dict_messages(self) -> None:
"""Test ChatMessageStoreState initialization with dict messages."""
messages = [
{"role": "user", "text": "Hello"},
{"role": "assistant", "text": "Hi there!"},
]
state = ChatMessageStoreState(messages=messages)
assert len(state.messages) == 2
assert isinstance(state.messages[0], Message)
assert state.messages[0].text == "Hello"
class TestChatMessageStoreEdgeCases:
"""Additional edge case tests for ChatMessageStore."""
async def test_deserialize_class_method(self) -> None:
"""Test ChatMessageStore.deserialize class method."""
serialized_data = {
"messages": [
{"role": "user", "text": "Hello", "message_id": "msg1"},
]
}
store = await ChatMessageStore.deserialize(serialized_data)
assert isinstance(store, ChatMessageStore)
messages = await store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Hello"
async def test_deserialize_empty_state(self) -> None:
"""Test ChatMessageStore.deserialize with empty state."""
serialized_data: dict[str, Any] = {"messages": []}
store = await ChatMessageStore.deserialize(serialized_data)
assert isinstance(store, ChatMessageStore)
messages = await store.list_messages()
assert len(messages) == 0
class TestAgentThreadEdgeCases:
"""Additional edge case tests for AgentThread."""
def test_is_initialized_with_service_thread_id(self) -> None:
"""Test is_initialized property when service_thread_id is set."""
thread = AgentThread(service_thread_id="test-123")
assert thread.is_initialized is True
def test_is_initialized_with_message_store(self) -> None:
"""Test is_initialized property when message_store is set."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
assert thread.is_initialized is True
def test_is_initialized_with_nothing(self) -> None:
"""Test is_initialized property when nothing is set."""
thread = AgentThread()
assert thread.is_initialized is False
async def test_deserialize_with_custom_message_store(self) -> None:
"""Test deserialize using a custom message store."""
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
custom_store = MockChatMessageStore()
thread = await AgentThread.deserialize(serialized_data, message_store=custom_store)
assert thread.message_store is custom_store
messages = await custom_store.list_messages()
assert len(messages) == 1
async def test_deserialize_with_failing_message_store_raises(self) -> None:
"""Test deserialize raises AgentThreadException when message store fails."""
class FailingStore:
async def add_messages(self, messages: Sequence[Message], **kwargs: Any) -> None:
raise RuntimeError("Store failed")
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
failing_store = FailingStore()
with pytest.raises(AgentThreadException, match="Failed to deserialize"):
await AgentThread.deserialize(serialized_data, message_store=failing_store)
async def test_update_from_thread_state_with_service_thread_id(self) -> None:
"""Test update_from_thread_state sets service_thread_id."""
thread = AgentThread()
serialized_data = {"service_thread_id": "new-thread-id"}
await thread.update_from_thread_state(serialized_data)
assert thread.service_thread_id == "new-thread-id"
async def test_update_from_thread_state_with_empty_chat_state(self) -> None:
"""Test update_from_thread_state with empty chat_message_store_state."""
thread = AgentThread()
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
await thread.update_from_thread_state(serialized_data)
assert thread.message_store is None
async def test_update_from_thread_state_creates_message_store(self) -> None:
"""Test update_from_thread_state creates message store if not existing."""
thread = AgentThread()
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
await thread.update_from_thread_state(serialized_data)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 1
@@ -14,7 +14,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
ChatResponse,
ChatResponseUpdate,
Content,
@@ -1264,70 +1264,70 @@ async def test_openai_assistants_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_agent_thread_persistence():
"""Test Agent thread persistence across runs with OpenAIAssistantsClient."""
async def test_openai_assistants_agent_session_persistence():
"""Test Agent session persistence across runs with OpenAIAssistantsClient."""
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
"Remember this number: 42. What number did I just tell you to remember?", session=session
)
assert isinstance(first_response, AgentResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
"What number did I tell you to remember in my previous message?", session=session
)
assert isinstance(second_response, AgentResponse)
assert "42" in second_response.text
# Verify thread has been populated with conversation ID
assert thread.service_thread_id is not None
# Verify session has been populated with conversation ID
assert session.service_session_id is not None
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_agent_existing_thread_id():
"""Test Agent with existing thread ID to continue conversations across agent instances."""
# First, create a conversation and capture the thread ID
existing_thread_id = None
async def test_openai_assistants_agent_existing_session_id():
"""Test Agent with existing session ID to continue conversations across agent instances."""
# First, create a conversation and capture the session ID
existing_session_id = None
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Start a conversation and get the thread ID
thread = agent.get_new_thread()
response1 = await agent.run("What's the weather in Paris?", thread=thread)
# Start a conversation and get the session ID
session = agent.create_session()
response1 = await agent.run("What's the weather in Paris?", session=session)
# Validate first response
assert isinstance(response1, AgentResponse)
assert response1.text is not None
assert any(word in response1.text.lower() for word in ["weather", "paris"])
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# The session ID is set after the first response
existing_session_id = session.service_session_id
assert existing_session_id is not None
# Now continue with the same thread ID in a new agent instance
# Now continue with the same session ID in a new agent instance
async with Agent(
client=OpenAIAssistantsClient(thread_id=existing_thread_id),
client=OpenAIAssistantsClient(thread_id=existing_session_id),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Create a session with the existing ID
session = AgentSession(service_session_id=existing_session_id)
# Ask about the previous conversation
response2 = await agent.run("What was the last city I asked about?", thread=thread)
response2 = await agent.run("What was the last city I asked about?", session=session)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentResponse)
@@ -7,9 +7,8 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
ChatMessageStore,
Content,
Message,
ResponseStream,
@@ -21,7 +20,7 @@ from agent_framework.orchestrations import SequentialBuilder
class _CountingAgent(BaseAgent):
"""Agent that echoes messages with a counter to verify thread state persistence."""
"""Agent that echoes messages with a counter to verify session state persistence."""
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
@@ -32,7 +31,7 @@ class _CountingAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
@@ -52,22 +51,22 @@ class _CountingAgent(BaseAgent):
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
"""Test that workflow checkpoint stores AgentExecutor's cache and thread states and restores them correctly."""
"""Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly."""
storage = InMemoryCheckpointStorage()
# Create initial agent with a custom thread that has a message store
# Create initial agent with a custom session
initial_agent = _CountingAgent(id="test_agent", name="TestAgent")
initial_thread = AgentThread(message_store=ChatMessageStore())
initial_session = AgentSession()
# Add some initial messages to the thread to verify thread state persistence
# Add some initial messages to the session state to verify session state persistence
initial_messages = [
Message(role="user", text="Initial message 1"),
Message(role="assistant", text="Initial response 1"),
]
await initial_thread.on_new_messages(initial_messages)
initial_session.state["history"] = {"messages": initial_messages}
# Create AgentExecutor with the thread
executor = AgentExecutor(initial_agent, agent_thread=initial_thread)
# Create AgentExecutor with the session
executor = AgentExecutor(initial_agent, session=initial_session)
# Build workflow with checkpointing enabled
wf = SequentialBuilder(participants=[executor], checkpoint_storage=storage).build()
@@ -95,7 +94,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
checkpoints.sort(key=lambda cp: cp.timestamp)
restore_checkpoint = checkpoints[1]
# Verify checkpoint contains executor state with both cache and thread
# Verify checkpoint contains executor state with both cache and session
assert "_executor_state" in restore_checkpoint.state
executor_states = restore_checkpoint.state["_executor_state"]
assert isinstance(executor_states, dict)
@@ -103,13 +102,12 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
executor_state = executor_states[executor.id] # type: ignore[index]
assert "cache" in executor_state, "Checkpoint should store executor cache state"
assert "agent_thread" in executor_state, "Checkpoint should store executor thread state"
assert "agent_session" in executor_state, "Checkpoint should store executor session state"
# Verify thread state includes message store
thread_state = executor_state["agent_thread"] # type: ignore[index]
assert "chat_message_store_state" in thread_state, "Thread state should include message store"
chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index]
assert "messages" in chat_store_state, "Message store state should include messages"
# Verify session state structure
session_state = executor_state["agent_session"] # type: ignore[index]
assert "session_id" in session_state, "Session state should include session_id"
assert "state" in session_state, "Session state should include state dict"
# Verify checkpoint contains pending requests from agents and responses to be sent
assert "pending_agent_requests" in executor_state
@@ -118,8 +116,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
# Create a new agent and executor for restoration
# This simulates starting from a fresh state and restoring from checkpoint
restored_agent = _CountingAgent(id="test_agent", name="TestAgent")
restored_thread = AgentThread(message_store=ChatMessageStore())
restored_executor = AgentExecutor(restored_agent, agent_thread=restored_thread)
restored_session = AgentSession()
restored_executor = AgentExecutor(restored_agent, session=restored_session)
# Verify the restored agent starts with a fresh state
assert restored_agent.call_count == 0
@@ -140,39 +138,27 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
assert resumed_output is not None
# Verify the restored executor's state matches the original
# The cache should be restored (though it may be cleared after processing)
# The thread should have all messages including those from the initial state
message_store = restored_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
assert message_store is not None
thread_messages = await message_store.list_messages()
# Thread should contain:
# 1. Initial messages from before the checkpoint (2 messages)
# 2. User message from first run (1 message)
# 3. Assistant response from first run (1 message)
assert len(thread_messages) >= 2, "Thread should preserve initial messages from before checkpoint"
# Verify initial messages are preserved
assert thread_messages[0].text == "Initial message 1"
assert thread_messages[1].text == "Initial response 1"
# Verify the restored executor's session state was restored
restored_session_obj = restored_executor._session # type: ignore[reportPrivateUsage]
assert restored_session_obj is not None
assert restored_session_obj.session_id == initial_session.session_id
async def test_agent_executor_save_and_restore_state_directly() -> None:
"""Test AgentExecutor's on_checkpoint_save and on_checkpoint_restore methods directly."""
# Create agent with thread containing messages
# Create agent with session containing state
agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
thread = AgentThread(message_store=ChatMessageStore())
session = AgentSession()
# Add messages to thread
thread_messages = [
Message(role="user", text="Message in thread 1"),
Message(role="assistant", text="Thread response 1"),
Message(role="user", text="Message in thread 2"),
# Add messages to session state
session_messages = [
Message(role="user", text="Message in session 1"),
Message(role="assistant", text="Session response 1"),
Message(role="user", text="Message in session 2"),
]
await thread.on_new_messages(thread_messages)
session.state["history"] = {"messages": session_messages}
executor = AgentExecutor(agent, agent_thread=thread)
executor = AgentExecutor(agent, session=session)
# Add messages to executor cache
cache_messages = [
@@ -184,26 +170,23 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
# Snapshot the state
state = await executor.on_checkpoint_save()
# Verify snapshot contains both cache and thread
# Verify snapshot contains both cache and session
assert "cache" in state
assert "agent_thread" in state
assert "agent_session" in state
# Verify thread state structure
thread_state = state["agent_thread"] # type: ignore[index]
assert "chat_message_store_state" in thread_state
assert "messages" in thread_state["chat_message_store_state"]
# Verify session state structure
session_state = state["agent_session"] # type: ignore[index]
assert "session_id" in session_state
assert "state" in session_state
# Create new executor to restore into
new_agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
new_thread = AgentThread(message_store=ChatMessageStore())
new_executor = AgentExecutor(new_agent, agent_thread=new_thread)
new_session = AgentSession()
new_executor = AgentExecutor(new_agent, session=new_session)
# Verify new executor starts empty
assert len(new_executor._cache) == 0 # type: ignore[reportPrivateUsage]
initial_message_store = new_thread.message_store
assert initial_message_store is not None
initial_thread_msgs = await initial_message_store.list_messages()
assert len(initial_thread_msgs) == 0
assert len(new_session.state) == 0
# Restore state
await new_executor.on_checkpoint_restore(state)
@@ -214,11 +197,6 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
assert restored_cache[0].text == "Cached user message"
assert restored_cache[1].text == "Cached assistant response"
# Verify thread messages are restored
restored_message_store = new_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
assert restored_message_store is not None
restored_thread_msgs = await restored_message_store.list_messages()
assert len(restored_thread_msgs) == len(thread_messages)
assert restored_thread_msgs[0].text == "Message in thread 1"
assert restored_thread_msgs[1].text == "Thread response 1"
assert restored_thread_msgs[2].text == "Message in thread 2"
# Verify session was restored with correct session_id
restored_session = new_executor._session # type: ignore[reportPrivateUsage]
assert restored_session.session_id == session.session_id
@@ -13,7 +13,7 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
ChatResponse,
ChatResponseUpdate,
@@ -42,7 +42,7 @@ class _ToolCallingAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -3,7 +3,7 @@
from collections.abc import AsyncIterable
from typing import Any
from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, Message
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Message
from agent_framework._workflows._agent_utils import resolve_agent_id
@@ -37,12 +37,12 @@ class MockAgent:
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: ...
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Creates a new conversation thread for the agent."""
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session for the agent."""
...
@@ -12,7 +12,7 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Executor,
@@ -38,7 +38,7 @@ class _SimpleAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -108,7 +108,7 @@ class _CaptureAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
# Normalize and record messages for verification
@@ -13,7 +13,7 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Executor,
@@ -838,7 +838,7 @@ class _StreamingTestAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -11,8 +11,7 @@ from agent_framework import (
AgentExecutorRequest,
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessageStore,
AgentSession,
Content,
Executor,
Message,
@@ -511,80 +510,53 @@ class TestWorkflowAgent:
texts = [message.text for message in result.messages]
assert texts == ["first message", "second message", "third fourth"]
async def test_thread_conversation_history_included_in_workflow_run(self) -> None:
"""Test that conversation history from thread is included when running WorkflowAgent.
This verifies that when a thread with existing messages is provided to agent.run(),
the workflow receives the complete conversation history (thread history + new messages).
"""
async def test_session_conversation_history_included_in_workflow_run(self) -> None:
"""Test that messages provided to agent.run() are passed through to the workflow."""
# Create an executor that captures all received messages
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing", streaming=False)
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Thread History Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Session History Test Agent")
# Create a thread with existing conversation history
history_messages = [
Message(role="user", text="Previous user message"),
Message(role="assistant", text="Previous assistant response"),
]
message_store = ChatMessageStore(messages=history_messages)
thread = AgentThread(message_store=message_store)
# Create a session
session = AgentSession()
# Run the agent with the thread and a new message
# Run the agent with the session and a new message
new_message = "New user question"
await agent.run(new_message, thread=thread)
await agent.run(new_message, session=session)
# Verify the executor received both history AND new message
assert len(capturing_executor.received_messages) == 3
# Verify the executor received the message
assert len(capturing_executor.received_messages) == 1
assert capturing_executor.received_messages[0].text == "New user question"
# Verify the order: history first, then new message
assert capturing_executor.received_messages[0].text == "Previous user message"
assert capturing_executor.received_messages[1].text == "Previous assistant response"
assert capturing_executor.received_messages[2].text == "New user question"
async def test_thread_conversation_history_included_in_workflow_stream(self) -> None:
"""Test that conversation history from thread is included when streaming WorkflowAgent.
This verifies that stream=True also includes thread history.
"""
async def test_session_conversation_history_included_in_workflow_stream(self) -> None:
"""Test that messages provided to agent.run() are passed through when streaming WorkflowAgent."""
# Create an executor that captures all received messages
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing_stream")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Thread Stream Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Session Stream Test Agent")
# Create a thread with existing conversation history
history_messages = [
Message(role="system", text="You are a helpful assistant"),
Message(role="user", text="Hello"),
Message("assistant", ["Hi there!"]),
]
message_store = ChatMessageStore(messages=history_messages)
thread = AgentThread(message_store=message_store)
# Create a session
session = AgentSession()
# Stream from the agent with the thread and a new message
async for _ in agent.run("How are you?", stream=True, thread=thread):
# Stream from the agent with the session and a new message
async for _ in agent.run("How are you?", stream=True, session=session):
pass
# Verify the executor received all messages (3 from history + 1 new)
assert len(capturing_executor.received_messages) == 4
# Verify the executor received the message
assert len(capturing_executor.received_messages) == 1
assert capturing_executor.received_messages[0].text == "How are you?"
# Verify the order
assert capturing_executor.received_messages[0].text == "You are a helpful assistant"
assert capturing_executor.received_messages[1].text == "Hello"
assert capturing_executor.received_messages[2].text == "Hi there!"
assert capturing_executor.received_messages[3].text == "How are you?"
async def test_empty_thread_works_correctly(self) -> None:
"""Test that an empty thread (no message store) works correctly."""
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_thread_test")
async def test_empty_session_works_correctly(self) -> None:
"""Test that an empty session (no message store) works correctly."""
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_session_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Empty Thread Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Empty Session Test Agent")
# Create an empty thread
thread = AgentThread()
# Create an empty session
session = AgentSession()
# Run with the empty thread
await agent.run("Just a new message", thread=thread)
# Run with the empty session
await agent.run("Just a new message", session=session)
# Should only receive the new message
assert len(capturing_executor.received_messages) == 1
@@ -622,27 +594,27 @@ class TestWorkflowAgent:
self.description: str | None = None
self._response_text = response_text
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession()
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
@@ -654,7 +626,7 @@ class TestWorkflowAgent:
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter():
@@ -710,27 +682,27 @@ class TestWorkflowAgent:
self.description: str | None = None
self._response_text = response_text
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession()
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
@@ -742,7 +714,7 @@ class TestWorkflowAgent:
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter():
@@ -1037,7 +1009,7 @@ class TestWorkflowAgentMergeUpdates:
def test_merge_updates_function_result_ordering_github_2977(self):
"""Test that FunctionResultContent updates are placed after their FunctionCallContent.
This test reproduces GitHub issue #2977: When using a thread with WorkflowAgent,
This test reproduces GitHub issue #2977: When using a session with WorkflowAgent,
FunctionResultContent updates without response_id were being added to global_dangling
and placed at the end of messages. This caused OpenAI to reject the conversation because
"An assistant message with 'tool_calls' must be followed by tool messages responding
@@ -9,7 +9,7 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Executor,
Message,
@@ -21,7 +21,7 @@ from agent_framework import (
class DummyAgent(BaseAgent):
def run(self, messages=None, *, stream: bool = False, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
def run(self, messages=None, *, stream: bool = False, session: AgentSession | None = None, **kwargs): # type: ignore[override]
if stream:
return self._run_stream_impl()
return self._run_impl(messages)
@@ -8,7 +8,7 @@ import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Message,
@@ -55,7 +55,7 @@ class _KwargsCapturingAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.captured_kwargs.append(dict(kwargs))
@@ -88,7 +88,7 @@ class _OptionsAwareAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: