mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add long-running agents and background responses support (#3808)
* Python: Add long-running agents and background responses support - Add ContinuationToken TypedDict to core types - Add continuation_token field to ChatResponse, ChatResponseUpdate, AgentResponse, and AgentResponseUpdate - Add background and continuation_token options to OpenAIResponsesOptions - Implement polling via responses.retrieve() and streaming resumption in RawOpenAIResponsesClient - Propagate continuation tokens through agent run() and map_chat_to_agent_update - Fix streaming telemetry 'Failed to detach context' error in both ChatTelemetryLayer and AgentTelemetryLayer by avoiding trace.use_span() context attachment for async-managed spans - Add 14 unit tests for continuation token types and background flows - Add background_responses sample showing polling and stream resumption Fixes #2478 * Python: Add A2A long-running task support via ContinuationToken - Make ContinuationToken provider-agnostic (total=False, optional task_id/context_id fields) - Add background param to A2AAgent.run() controlling token emission - Add poll_task() for single-request task state retrieval - Add resubscribe support via continuation_token param on run() - Extract _updates_from_task() and _map_a2a_stream() for cleaner code - Streamline run()/streaming by removing intermediate _stream_updates wrapper - Update A2A sample to show background=False (default) with link to background_responses sample - Remove stale BareAgent from __all__ - Add 12 new A2A continuation token tests * fix logic for overriding continuation token when done * refactored ContinuationToken setup
This commit is contained in:
committed by
GitHub
Unverified
parent
32ba81e990
commit
35097d8c75
@@ -2434,3 +2434,263 @@ async def test_integration_streaming_file_search() -> None:
|
||||
|
||||
assert "sunny" in full_message.lower()
|
||||
assert "75" in full_message
|
||||
|
||||
|
||||
# region Background Response / ContinuationToken Tests
|
||||
|
||||
|
||||
def test_continuation_token_json_serializable() -> None:
|
||||
"""Test that OpenAIContinuationToken is a plain dict and JSON-serializable."""
|
||||
from agent_framework.openai import OpenAIContinuationToken
|
||||
|
||||
token = OpenAIContinuationToken(response_id="resp_abc123")
|
||||
assert token["response_id"] == "resp_abc123"
|
||||
|
||||
# JSON round-trip
|
||||
serialized = json.dumps(token)
|
||||
restored = json.loads(serialized)
|
||||
assert restored["response_id"] == "resp_abc123"
|
||||
|
||||
|
||||
def test_chat_response_with_continuation_token() -> None:
|
||||
"""Test that ChatResponse accepts and stores continuation_token."""
|
||||
from agent_framework.openai import OpenAIContinuationToken
|
||||
|
||||
token = OpenAIContinuationToken(response_id="resp_123")
|
||||
response = ChatResponse(
|
||||
messages=ChatMessage(role="assistant", contents=[Content.from_text(text="Hello")]),
|
||||
response_id="resp_123",
|
||||
continuation_token=token,
|
||||
)
|
||||
assert response.continuation_token is not None
|
||||
assert response.continuation_token["response_id"] == "resp_123"
|
||||
|
||||
|
||||
def test_chat_response_without_continuation_token() -> None:
|
||||
"""Test that ChatResponse defaults continuation_token to None."""
|
||||
response = ChatResponse(
|
||||
messages=ChatMessage(role="assistant", contents=[Content.from_text(text="Hello")]),
|
||||
)
|
||||
assert response.continuation_token is None
|
||||
|
||||
|
||||
def test_chat_response_update_with_continuation_token() -> None:
|
||||
"""Test that ChatResponseUpdate accepts and stores continuation_token."""
|
||||
from agent_framework.openai import OpenAIContinuationToken
|
||||
|
||||
token = OpenAIContinuationToken(response_id="resp_456")
|
||||
update = ChatResponseUpdate(
|
||||
contents=[Content.from_text(text="chunk")],
|
||||
role="assistant",
|
||||
continuation_token=token,
|
||||
)
|
||||
assert update.continuation_token is not None
|
||||
assert update.continuation_token["response_id"] == "resp_456"
|
||||
|
||||
|
||||
def test_agent_response_with_continuation_token() -> None:
|
||||
"""Test that AgentResponse accepts and stores continuation_token."""
|
||||
from agent_framework import AgentResponse
|
||||
from agent_framework.openai import OpenAIContinuationToken
|
||||
|
||||
token = OpenAIContinuationToken(response_id="resp_789")
|
||||
response = AgentResponse(
|
||||
messages=ChatMessage(role="assistant", contents=[Content.from_text(text="done")]),
|
||||
continuation_token=token,
|
||||
)
|
||||
assert response.continuation_token is not None
|
||||
assert response.continuation_token["response_id"] == "resp_789"
|
||||
|
||||
|
||||
def test_agent_response_update_with_continuation_token() -> None:
|
||||
"""Test that AgentResponseUpdate accepts and stores continuation_token."""
|
||||
from agent_framework import AgentResponseUpdate
|
||||
from agent_framework.openai import OpenAIContinuationToken
|
||||
|
||||
token = OpenAIContinuationToken(response_id="resp_012")
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="streaming")],
|
||||
role="assistant",
|
||||
continuation_token=token,
|
||||
)
|
||||
assert update.continuation_token is not None
|
||||
assert update.continuation_token["response_id"] == "resp_012"
|
||||
|
||||
|
||||
def test_parse_response_from_openai_with_background_in_progress() -> None:
|
||||
"""Test that _parse_response_from_openai sets continuation_token when status is in_progress."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
mock_response.metadata = {}
|
||||
mock_response.usage = None
|
||||
mock_response.id = "resp_bg_123"
|
||||
mock_response.model = "test-model"
|
||||
mock_response.created_at = 1000000000
|
||||
mock_response.status = "in_progress"
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.type = "message"
|
||||
mock_message.content = []
|
||||
mock_response.output = [mock_message]
|
||||
|
||||
options: dict[str, Any] = {"store": False}
|
||||
result = client._parse_response_from_openai(mock_response, options=options)
|
||||
|
||||
assert result.continuation_token is not None
|
||||
assert result.continuation_token["response_id"] == "resp_bg_123"
|
||||
|
||||
|
||||
def test_parse_response_from_openai_with_background_queued() -> None:
|
||||
"""Test that _parse_response_from_openai sets continuation_token when status is queued."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
mock_response.metadata = {}
|
||||
mock_response.usage = None
|
||||
mock_response.id = "resp_bg_456"
|
||||
mock_response.model = "test-model"
|
||||
mock_response.created_at = 1000000000
|
||||
mock_response.status = "queued"
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.type = "message"
|
||||
mock_message.content = []
|
||||
mock_response.output = [mock_message]
|
||||
|
||||
options: dict[str, Any] = {"store": False}
|
||||
result = client._parse_response_from_openai(mock_response, options=options)
|
||||
|
||||
assert result.continuation_token is not None
|
||||
assert result.continuation_token["response_id"] == "resp_bg_456"
|
||||
|
||||
|
||||
def test_parse_response_from_openai_with_background_completed() -> None:
|
||||
"""Test that _parse_response_from_openai does NOT set continuation_token when status is completed."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
mock_response.metadata = {}
|
||||
mock_response.usage = None
|
||||
mock_response.id = "resp_bg_789"
|
||||
mock_response.model = "test-model"
|
||||
mock_response.created_at = 1000000000
|
||||
mock_response.status = "completed"
|
||||
|
||||
mock_text_content = MagicMock()
|
||||
mock_text_content.type = "output_text"
|
||||
mock_text_content.text = "Final answer"
|
||||
mock_text_content.annotations = []
|
||||
mock_text_content.logprobs = None
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.type = "message"
|
||||
mock_message.content = [mock_text_content]
|
||||
mock_response.output = [mock_message]
|
||||
|
||||
options: dict[str, Any] = {"store": False}
|
||||
result = client._parse_response_from_openai(mock_response, options=options)
|
||||
|
||||
assert result.continuation_token is None
|
||||
|
||||
|
||||
def test_streaming_response_in_progress_sets_continuation_token() -> None:
|
||||
"""Test that _parse_chunk_from_openai sets continuation_token for in_progress events."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options: dict[str, Any] = {}
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.in_progress"
|
||||
mock_event.response = MagicMock()
|
||||
mock_event.response.id = "resp_stream_123"
|
||||
mock_event.response.conversation = MagicMock()
|
||||
mock_event.response.conversation.id = "conv_456"
|
||||
mock_event.response.status = "in_progress"
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert update.continuation_token is not None
|
||||
assert update.continuation_token["response_id"] == "resp_stream_123"
|
||||
|
||||
|
||||
def test_streaming_response_created_with_in_progress_status_sets_continuation_token() -> None:
|
||||
"""Test that response.created with in_progress status sets continuation_token."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options: dict[str, Any] = {}
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.created"
|
||||
mock_event.response = MagicMock()
|
||||
mock_event.response.id = "resp_created_123"
|
||||
mock_event.response.conversation = MagicMock()
|
||||
mock_event.response.conversation.id = "conv_789"
|
||||
mock_event.response.status = "in_progress"
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert update.continuation_token is not None
|
||||
assert update.continuation_token["response_id"] == "resp_created_123"
|
||||
|
||||
|
||||
def test_streaming_response_completed_no_continuation_token() -> None:
|
||||
"""Test that response.completed does NOT set continuation_token."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options: dict[str, Any] = {}
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.completed"
|
||||
mock_event.response = MagicMock()
|
||||
mock_event.response.id = "resp_done_123"
|
||||
mock_event.response.conversation = MagicMock()
|
||||
mock_event.response.conversation.id = "conv_done"
|
||||
mock_event.response.model = "test-model"
|
||||
mock_event.response.usage = None
|
||||
|
||||
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert update.continuation_token is None
|
||||
|
||||
|
||||
def test_map_chat_to_agent_update_preserves_continuation_token() -> None:
|
||||
"""Test that map_chat_to_agent_update propagates continuation_token."""
|
||||
from agent_framework._types import map_chat_to_agent_update
|
||||
|
||||
token = {"response_id": "resp_map_123"}
|
||||
chat_update = ChatResponseUpdate(
|
||||
contents=[Content.from_text(text="chunk")],
|
||||
role="assistant",
|
||||
response_id="resp_map_123",
|
||||
continuation_token=token,
|
||||
)
|
||||
|
||||
agent_update = map_chat_to_agent_update(chat_update, agent_name="test-agent")
|
||||
|
||||
assert agent_update.continuation_token is not None
|
||||
assert agent_update.continuation_token["response_id"] == "resp_map_123"
|
||||
|
||||
|
||||
async def test_prepare_options_excludes_continuation_token() -> None:
|
||||
"""Test that _prepare_options does not pass continuation_token to OpenAI API."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
options: dict[str, Any] = {
|
||||
"model_id": "test-model",
|
||||
"continuation_token": {"response_id": "resp_123"},
|
||||
"background": True,
|
||||
}
|
||||
|
||||
run_options = await client._prepare_options(messages, options)
|
||||
|
||||
assert "continuation_token" not in run_options
|
||||
assert "background" in run_options
|
||||
assert run_options["background"] is True
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
Reference in New Issue
Block a user