From eb30ac31ce416ccb581547db6764baeca5971a58 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Mon, 13 Apr 2026 07:54:32 +0000 Subject: [PATCH 1/4] feat: add a `TextClient` class for a simplified text-based communication --- samples/cli.py | 13 ++-- samples/text_client_cli.py | 51 ++++++++++++++ src/a2a/client/__init__.py | 4 ++ src/a2a/client/client_factory.py | 42 ++++++++++++ src/a2a/client/text_client.py | 64 ++++++++++++++++++ tests/client/test_text_client.py | 111 +++++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 samples/text_client_cli.py create mode 100644 src/a2a/client/text_client.py create mode 100644 tests/client/test_text_client.py diff --git a/samples/cli.py b/samples/cli.py index 8515fd5a9..fdb8a5370 100644 --- a/samples/cli.py +++ b/samples/cli.py @@ -11,25 +11,22 @@ from a2a.client import A2ACardResolver, ClientConfig, create_client from a2a.types import Message, Part, Role, SendMessageRequest, TaskState +from a2a.utils.message import get_message_text async def _handle_stream( stream: Any, current_task_id: str | None ) -> str | None: - async for event, task in stream: - if not task: - continue + async for event in stream: if not current_task_id: - current_task_id = task.id - + current_task_id = event.task.id if event: if event.HasField('status_update'): state_name = TaskState.Name(event.status_update.status.state) print(f'TaskStatusUpdate [state={state_name}]:', end=' ') if event.status_update.status.HasField('message'): - for part in event.status_update.status.message.parts: - if part.text: - print(part.text, end=' ') + message = event.status_update.status.message + print(get_message_text(message, delimiter=' ')) print() if ( diff --git a/samples/text_client_cli.py b/samples/text_client_cli.py new file mode 100644 index 000000000..aa15c08d4 --- /dev/null +++ b/samples/text_client_cli.py @@ -0,0 +1,51 @@ +import argparse +import asyncio + +import grpc +import httpx + +from a2a.client import A2ACardResolver, create_text_client + + +async def main() -> None: + """Run the simple A2A terminal client using TextClient.""" + parser = argparse.ArgumentParser(description='A2A Simple Text Client') + parser.add_argument( + '--url', default='http://127.0.0.1:41241', help='Agent base URL' + ) + args = parser.parse_args() + + print(f'Connecting to {args.url}') + + async with httpx.AsyncClient() as httpx_client: + resolver = A2ACardResolver(httpx_client, args.url) + card = await resolver.get_agent_card() + print(f'\n✓ Agent Card Found: {card.name}') + + text_client = await create_text_client(card) + + print('\nConnected! Send a message or type /quit to exit.') + + while True: + try: + loop = asyncio.get_running_loop() + user_input = await loop.run_in_executor(None, input, 'You: ') + except KeyboardInterrupt: + break + + if user_input.lower() in ('/quit', '/exit'): + break + if not user_input.strip(): + continue + + try: + response = await text_client.send_text_message(user_input) + print(f'Agent: {response}') + except (httpx.RequestError, grpc.RpcError) as e: + print(f'Error communicating with agent: {e}') + + await text_client.close() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/src/a2a/client/__init__.py b/src/a2a/client/__init__.py index c23041f32..4a6d05a27 100644 --- a/src/a2a/client/__init__.py +++ b/src/a2a/client/__init__.py @@ -15,6 +15,7 @@ from a2a.client.client_factory import ( ClientFactory, create_client, + create_text_client, minimal_agent_card, ) from a2a.client.errors import ( @@ -24,6 +25,7 @@ ) from a2a.client.helpers import create_text_message_object from a2a.client.interceptors import ClientCallInterceptor +from a2a.client.text_client import TextClient __all__ = [ @@ -40,7 +42,9 @@ 'ClientFactory', 'CredentialService', 'InMemoryContextCredentialStore', + 'TextClient', 'create_client', + 'create_text_client', 'create_text_message_object', 'minimal_agent_card', ] diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index a59189ade..320b415c3 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -12,6 +12,7 @@ from a2a.client.base_client import BaseClient from a2a.client.card_resolver import A2ACardResolver from a2a.client.client import Client, ClientConfig +from a2a.client.text_client import TextClient from a2a.client.transports.base import ClientTransport from a2a.client.transports.jsonrpc import JsonRpcTransport from a2a.client.transports.rest import RestTransport @@ -406,6 +407,47 @@ async def create_client( # noqa: PLR0913 return factory.create(agent, interceptors) +async def create_text_client( # noqa: PLR0913 + agent: str | AgentCard, + client_config: ClientConfig | None = None, + interceptors: list[ClientCallInterceptor] | None = None, + relative_card_path: str | None = None, + resolver_http_kwargs: dict[str, Any] | None = None, + signature_verifier: Callable[[AgentCard], None] | None = None, +) -> TextClient: + """Create a `TextClient` for an agent from a URL or `AgentCard`. + + Convenience function that constructs a `ClientFactory` internally. + For reusing a factory across multiple agents or registering custom + transports, use `ClientFactory` directly instead. + + Args: + agent: The base URL of the agent, or an `AgentCard` to use + directly. + client_config: Optional `ClientConfig`. A default config is + created if not provided. + interceptors: A list of interceptors to use for each request. + relative_card_path: The relative path when resolving the agent + card. Only used when `agent` is a URL. + resolver_http_kwargs: Dictionary of arguments to provide to the + httpx client when resolving the agent card. + signature_verifier: A callable used to verify the agent card's + signatures. + + Returns: + A `TextClient` wrapping the constructed `Client`. + """ + client = await create_client( + agent=agent, + client_config=client_config, + interceptors=interceptors, + relative_card_path=relative_card_path, + resolver_http_kwargs=resolver_http_kwargs, + signature_verifier=signature_verifier, + ) + return TextClient(client) + + def minimal_agent_card( url: str, transports: list[str] | None = None ) -> AgentCard: diff --git a/src/a2a/client/text_client.py b/src/a2a/client/text_client.py new file mode 100644 index 000000000..02c488aee --- /dev/null +++ b/src/a2a/client/text_client.py @@ -0,0 +1,64 @@ +import uuid + +from a2a.client.client import Client, ClientCallContext +from a2a.types import Message, Part, Role, SendMessageRequest + + +class TextClient: + """A facade around Client that simplifies text-based communication. + + Wraps an underlying Client instance and exposes a simplified interface + for sending plain-text messages and receiving aggregated text responses. + For full Client API access, use the underlying client directly via + the `client` property. + """ + + def __init__(self, client: Client): + self._client = client + + @property + def client(self) -> Client: + """Returns the underlying Client instance for full API access.""" + return self._client + + async def send_text_message( + self, + text: str, + *, + context: ClientCallContext | None = None, + ) -> str: + """Sends a text message and returns the aggregated text response.""" + request = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id=str(uuid.uuid4()), + parts=[Part(text=text)], + ) + ) + + response_parts: list[str] = [] + + async for event in self._client.send_message(request, context=context): + if event.HasField('message'): + response_parts.extend( + part.text for part in event.message.parts if part.text + ) + elif event.HasField('status_update'): + if event.status_update.status.HasField('message'): + response_parts.extend( + part.text + for part in event.status_update.status.message.parts + if part.text + ) + elif event.HasField('artifact_update'): + response_parts.extend( + part.text + for part in event.artifact_update.artifact.parts + if part.text + ) + + return ' '.join(response_parts) + + async def close(self) -> None: + """Closes the underlying client.""" + await self._client.close() diff --git a/tests/client/test_text_client.py b/tests/client/test_text_client.py new file mode 100644 index 000000000..560818acf --- /dev/null +++ b/tests/client/test_text_client.py @@ -0,0 +1,111 @@ +from unittest.mock import AsyncMock + +import pytest + +from a2a.client import ( + Client, + ClientConfig, + ClientCallContext, + create_text_client, + minimal_agent_card, + TextClient, +) +from a2a.types import Part, StreamResponse + + +@pytest.fixture +def mock_client() -> AsyncMock: + return AsyncMock(spec=Client) + + +@pytest.fixture +def text_client(mock_client: AsyncMock) -> TextClient: + return TextClient(mock_client) + + +def test_client_property( + text_client: TextClient, mock_client: AsyncMock +) -> None: + assert text_client.client is mock_client + + +@pytest.mark.asyncio +async def test_create_client_and_wrap() -> None: + # Create a minimal card + card = minimal_agent_card(url='http://test.com', transports=['JSONRPC']) + + config = ClientConfig(supported_protocol_bindings=['JSONRPC']) + + text_client = await create_text_client(card, client_config=config) + + assert isinstance(text_client, TextClient) + assert isinstance(text_client.client, Client) + + # Clean up + await text_client.close() + + +@pytest.mark.asyncio +async def test_send_text_message( + text_client: TextClient, mock_client: AsyncMock +) -> None: + async def create_stream(*args, **kwargs): + # Event 0: task (ignored) + resp0 = StreamResponse() + resp0.task.id = 'task-1' + yield resp0 + + # Event 1: direct message + resp1 = StreamResponse() + resp1.message.parts.append(Part(text='Hello')) + yield resp1 + + # Event 2: status update without message + resp2 = StreamResponse() + resp2.status_update.status.state = 1 + yield resp2 + + # Event 3: status update with message + resp3 = StreamResponse() + resp3.status_update.status.message.parts.append(Part(text='Processing')) + yield resp3 + + # Event 4: artifact update + resp4 = StreamResponse() + resp4.artifact_update.artifact.parts.append(Part(text='World!')) + yield resp4 + + mock_client.send_message.return_value = create_stream() + + response = await text_client.send_text_message('Hi') + + assert response == 'Hello Processing World!' + mock_client.send_message.assert_called_once() + # Verify request construction + args, _ = mock_client.send_message.call_args + request = args[0] + assert request.message.parts[0].text == 'Hi' + + +@pytest.mark.asyncio +async def test_send_text_message_forwards_context( + text_client: TextClient, mock_client: AsyncMock +) -> None: + + async def empty_stream(*args, **kwargs): + return + yield + + mock_client.send_message.return_value = empty_stream() + context = ClientCallContext() + + await text_client.send_text_message('Hi', context=context) + + _, kwargs = mock_client.send_message.call_args + assert kwargs['context'] is context + + +@pytest.mark.asyncio +async def test_close(text_client: TextClient, mock_client: AsyncMock) -> None: + await text_client.close() + mock_client.close.assert_awaited_once() From 95b224e9ed7228202f37e1ab491a2c41a51f7340 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Mon, 13 Apr 2026 10:02:53 +0000 Subject: [PATCH 2/4] Add README.md, task_id persistence --- samples/README.md | 97 +++++++++++++++++++++++ samples/cli.py | 2 + samples/text_client_cli.py | 27 ++++++- src/a2a/client/text_client.py | 80 +++++++++++++++---- tests/client/test_text_client.py | 129 +++++++++++++++++++++++++++++-- 5 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 samples/README.md diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 000000000..5034c1baf --- /dev/null +++ b/samples/README.md @@ -0,0 +1,97 @@ +# A2A Python SDK — Samples + +This directory contains runnable examples demonstrating how to build and interact with an A2A-compliant agent using the Python SDK. + +## Contents + +| File | Role | Description | +|---|---|---| +| `hello_world_agent.py` | **Server** | A2A agent server | +| `cli.py` | **Client** | Interactive terminal client | +| `text_client_cli.py` | **Client** | Simplified text-only interactive terminal client | + +All three samples are designed to work together out of the box: the agent listens on `http://127.0.0.1:41241`, which is the default URL used by both clients. +--- + +## `hello_world_agent.py` — Agent Server + +Implements an A2A agent that responds to simple greeting messages (e.g., "hello", "how are you", "bye") with text replies, simulating a 1-second processing delay. + +Demonstrates: +- Subclassing `AgentExecutor` and implementing `execute()` / `cancel()` +- Publishing streaming status updates and artifacts via `TaskUpdater` +- Exposing all three transports in both protocol versions (v1.0 and v0.3 compat) simultaneously: + - **JSON-RPC** (v1.0 and v0.3) at `http://127.0.0.1:41241/a2a/jsonrpc` + - **HTTP+JSON (REST)** (v1.0 and v0.3) at `http://127.0.0.1:41241/a2a/rest` + - **gRPC v1.0** on port `50051` + - **gRPC v0.3 (compat)** on port `50052` +- Serving the agent card at `http://127.0.0.1:41241/.well-known/agent-card.json` + +**Run:** + +```bash +uv run python samples/hello_world_agent.py +``` + +--- + +## `cli.py` — Client + +An interactive terminal client with full visibility into the streaming event flow. Each `TaskStatusUpdate` and `TaskArtifactUpdate` event is printed as it arrives. + +Features: +- Transport selection via `--transport` flag (`JSONRPC`, `HTTP+JSON`, `GRPC`) +- Session management (`context_id` persisted across messages, `task_id` per task) +- Graceful error handling for HTTP and gRPC failures + +**Run:** + +```bash +# Connect to the local hello_world_agent (default): +uv run python samples/cli.py + +# Connect to a different URL, using gRPC: +uv run python samples/cli.py --url http://192.168.1.10:41241 --transport GRPC +``` + +Type `/quit` or `/exit` to stop, or press `Ctrl+C`. + +--- + +## `text_client_cli.py` — Simple Text Client + +A stripped-down interactive client using the high-level `TextClient` abstraction. It hides all streaming and event mechanics, presenting a simple request/response interface. + +Ideal for understanding the **minimum code required** to call an A2A agent. + +**Run:** + +```bash +# Connect to the local hello_world_agent (default): +uv run python samples/text_client_cli.py + +# Connect to a different URL: +uv run python samples/text_client_cli.py --url http://192.168.1.10:41241 + +# Use a specific transport: +uv run python samples/text_client_cli.py --transport GRPC +``` + +Type `/quit` or `/exit` to stop, or press `Ctrl+C`. + +--- + + +## Quick Start + +In two separate terminals: + +```bash +# Terminal 1 — start the agent +uv run python samples/hello_world_agent.py + +# Terminal 2 — start the client +uv run python samples/cli.py +``` + +Then type a message like `hello` and press Enter. diff --git a/samples/cli.py b/samples/cli.py index fdb8a5370..dc7eb18ca 100644 --- a/samples/cli.py +++ b/samples/cli.py @@ -65,6 +65,8 @@ async def main() -> None: config = ClientConfig() if args.transport: config.supported_protocol_bindings = [args.transport] + if args.transport == 'GRPC': + config.grpc_channel_factory = grpc.aio.insecure_channel print( f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})' diff --git a/samples/text_client_cli.py b/samples/text_client_cli.py index aa15c08d4..67fd33ea8 100644 --- a/samples/text_client_cli.py +++ b/samples/text_client_cli.py @@ -4,7 +4,7 @@ import grpc import httpx -from a2a.client import A2ACardResolver, create_text_client +from a2a.client import A2ACardResolver, ClientConfig, create_text_client async def main() -> None: @@ -13,16 +13,35 @@ async def main() -> None: parser.add_argument( '--url', default='http://127.0.0.1:41241', help='Agent base URL' ) + parser.add_argument( + '--transport', + default=None, + help='Preferred transport (JSONRPC, HTTP+JSON, GRPC)', + ) args = parser.parse_args() - print(f'Connecting to {args.url}') + config = ClientConfig() + if args.transport: + config.supported_protocol_bindings = [args.transport] + if args.transport == 'GRPC': + config.grpc_channel_factory = grpc.aio.insecure_channel + + print( + f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})' + ) async with httpx.AsyncClient() as httpx_client: resolver = A2ACardResolver(httpx_client, args.url) card = await resolver.get_agent_card() - print(f'\n✓ Agent Card Found: {card.name}') + print('\n✓ Agent Card Found:') + print(f' Name: {card.name}') + + text_client = await create_text_client(card, client_config=config) - text_client = await create_text_client(card) + actual_transport = getattr( + text_client.client, '_transport', text_client.client + ) + print(f' Picked Transport: {actual_transport.__class__.__name__}') print('\nConnected! Send a message or type /quit to exit.') diff --git a/src/a2a/client/text_client.py b/src/a2a/client/text_client.py index 02c488aee..10b466dfc 100644 --- a/src/a2a/client/text_client.py +++ b/src/a2a/client/text_client.py @@ -1,7 +1,22 @@ import uuid +from types import TracebackType + +from typing_extensions import Self + from a2a.client.client import Client, ClientCallContext -from a2a.types import Message, Part, Role, SendMessageRequest +from a2a.types import Message, Part, Role, SendMessageRequest, TaskState +from a2a.utils import get_artifact_text, get_message_text + + +_TERMINAL_STATES: frozenset[TaskState] = frozenset( + { + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + } +) class TextClient: @@ -9,29 +24,64 @@ class TextClient: Wraps an underlying Client instance and exposes a simplified interface for sending plain-text messages and receiving aggregated text responses. + Maintains session state (context_id, task_id) automatically across calls. For full Client API access, use the underlying client directly via the `client` property. """ def __init__(self, client: Client): self._client = client + self._context_id: str = str(uuid.uuid4()) + self._task_id: str | None = None + + async def __aenter__(self) -> Self: + """Enters the async context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exits the async context manager and closes the client.""" + await self.close() @property def client(self) -> Client: """Returns the underlying Client instance for full API access.""" return self._client + def reset_session(self) -> None: + """Starts a new session by generating a fresh context ID and clearing the task ID.""" + self._context_id = str(uuid.uuid4()) + self._task_id = None + async def send_text_message( self, text: str, *, + delimiter: str = ' ', context: ClientCallContext | None = None, ) -> str: - """Sends a text message and returns the aggregated text response.""" + """Sends a text message and returns the aggregated text response. + + Session state (context_id, task_id) is managed automatically across + calls. Use reset_session() to start a new conversation. + + Args: + text: The plain-text message to send. + delimiter: String used to join response parts. Defaults to a + single space. Use '' for token-streamed responses or '\\n' + for paragraph-separated chunks. + context: Optional call-level context. + """ request = SendMessageRequest( message=Message( role=Role.ROLE_USER, message_id=str(uuid.uuid4()), + context_id=self._context_id, + task_id=self._task_id, parts=[Part(text=text)], ) ) @@ -39,25 +89,25 @@ async def send_text_message( response_parts: list[str] = [] async for event in self._client.send_message(request, context=context): - if event.HasField('message'): - response_parts.extend( - part.text for part in event.message.parts if part.text - ) + if event.HasField('task'): + self._task_id = event.task.id + elif event.HasField('message'): + response_parts.append(get_message_text(event.message)) elif event.HasField('status_update'): + if event.status_update.task_id: + self._task_id = event.status_update.task_id + if event.status_update.status.state in _TERMINAL_STATES: + self._task_id = None if event.status_update.status.HasField('message'): - response_parts.extend( - part.text - for part in event.status_update.status.message.parts - if part.text + response_parts.append( + get_message_text(event.status_update.status.message) ) elif event.HasField('artifact_update'): - response_parts.extend( - part.text - for part in event.artifact_update.artifact.parts - if part.text + response_parts.append( + get_artifact_text(event.artifact_update.artifact) ) - return ' '.join(response_parts) + return delimiter.join(response_parts) async def close(self) -> None: """Closes the underlying client.""" diff --git a/tests/client/test_text_client.py b/tests/client/test_text_client.py index 560818acf..1934e581c 100644 --- a/tests/client/test_text_client.py +++ b/tests/client/test_text_client.py @@ -4,13 +4,13 @@ from a2a.client import ( Client, - ClientConfig, ClientCallContext, + ClientConfig, + TextClient, create_text_client, minimal_agent_card, - TextClient, ) -from a2a.types import Part, StreamResponse +from a2a.types import Part, StreamResponse, TaskState @pytest.fixture @@ -81,17 +81,32 @@ async def create_stream(*args, **kwargs): assert response == 'Hello Processing World!' mock_client.send_message.assert_called_once() - # Verify request construction args, _ = mock_client.send_message.call_args request = args[0] assert request.message.parts[0].text == 'Hi' @pytest.mark.asyncio -async def test_send_text_message_forwards_context( +async def test_send_text_message_custom_delimiter( text_client: TextClient, mock_client: AsyncMock ) -> None: + async def create_stream(*args, **kwargs): + resp1 = StreamResponse() + resp1.message.parts.append(Part(text='Hello')) + yield resp1 + resp2 = StreamResponse() + resp2.artifact_update.artifact.parts.append(Part(text='World')) + yield resp2 + + mock_client.send_message.return_value = create_stream() + response = await text_client.send_text_message('Hi', delimiter='\n') + assert response == 'Hello\nWorld' + +@pytest.mark.asyncio +async def test_send_text_message_forwards_context( + text_client: TextClient, mock_client: AsyncMock +) -> None: async def empty_stream(*args, **kwargs): return yield @@ -105,6 +120,110 @@ async def empty_stream(*args, **kwargs): assert kwargs['context'] is context +def test_reset_session_changes_context_id(text_client: TextClient) -> None: + # Access internal state only to verify reset behaviour, not as public API + original = text_client._context_id + text_client.reset_session() + assert text_client._context_id != original + assert text_client._task_id is None + + +@pytest.mark.asyncio +async def test_send_text_message_sets_task_id_from_task_event( + text_client: TextClient, mock_client: AsyncMock +) -> None: + async def create_stream(*args, **kwargs): + resp = StreamResponse() + resp.task.id = 'task-123' + yield resp + + mock_client.send_message.return_value = create_stream() + await text_client.send_text_message('Hi') + assert text_client._task_id == 'task-123' + + +@pytest.mark.asyncio +async def test_send_text_message_sets_task_id_from_status_update( + text_client: TextClient, mock_client: AsyncMock +) -> None: + async def create_stream(*args, **kwargs): + resp = StreamResponse() + resp.status_update.task_id = 'task-456' + resp.status_update.status.state = 1 + yield resp + + mock_client.send_message.return_value = create_stream() + await text_client.send_text_message('Hi') + assert text_client._task_id == 'task-456' + + +@pytest.mark.asyncio +async def test_session_ids_passed_in_request( + text_client: TextClient, mock_client: AsyncMock +) -> None: + async def create_stream(*args, **kwargs): + resp = StreamResponse() + resp.task.id = 'task-789' + yield resp + + mock_client.send_message.return_value = create_stream() + context_id = text_client._context_id + + await text_client.send_text_message('Hi') + + args, _ = mock_client.send_message.call_args + request = args[0] + assert request.message.context_id == context_id + assert not request.message.task_id + + # Second call carries the task_id from the first + async def create_stream2(*args, **kwargs): + return + yield + + mock_client.send_message.return_value = create_stream2() + await text_client.send_text_message('Follow up') + + args, _ = mock_client.send_message.call_args + request = args[0] + assert request.message.context_id == context_id + assert request.message.task_id == 'task-789' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'terminal_state', + [ + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + ], +) +async def test_task_id_cleared_on_terminal_state( + text_client: TextClient, + mock_client: AsyncMock, + terminal_state: TaskState, +) -> None: + async def create_stream(*args, **kwargs): + resp = StreamResponse() + resp.status_update.task_id = 'task-abc' + resp.status_update.status.state = terminal_state + yield resp + + mock_client.send_message.return_value = create_stream() + await text_client.send_text_message('Hi') + assert text_client._task_id is None + + +@pytest.mark.asyncio +async def test_async_context_manager(mock_client: AsyncMock) -> None: + async with TextClient(mock_client) as client: + assert isinstance(client, TextClient) + mock_client.close.assert_not_awaited() + mock_client.close.assert_awaited_once() + + @pytest.mark.asyncio async def test_close(text_client: TextClient, mock_client: AsyncMock) -> None: await text_client.close() From 03366d9811d2fa6f76a7ffc7c2e0480882563417 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Mon, 13 Apr 2026 10:06:30 +0000 Subject: [PATCH 3/4] fix --- src/a2a/client/text_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/a2a/client/text_client.py b/src/a2a/client/text_client.py index 10b466dfc..2caf9f7fb 100644 --- a/src/a2a/client/text_client.py +++ b/src/a2a/client/text_client.py @@ -72,8 +72,8 @@ async def send_text_message( Args: text: The plain-text message to send. delimiter: String used to join response parts. Defaults to a - single space. Use '' for token-streamed responses or '\\n' - for paragraph-separated chunks. + single space. Use '' for token-streamed responses or a + newline for paragraph-separated chunks. context: Optional call-level context. """ request = SendMessageRequest( From 82ac284c61d3e75491900524cf40d009e007b304 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Wed, 15 Apr 2026 14:11:49 +0000 Subject: [PATCH 4/4] few small fixes --- src/a2a/client/text_client.py | 2 +- tests/client/test_text_client.py | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/a2a/client/text_client.py b/src/a2a/client/text_client.py index 2caf9f7fb..90e5040aa 100644 --- a/src/a2a/client/text_client.py +++ b/src/a2a/client/text_client.py @@ -94,7 +94,7 @@ async def send_text_message( elif event.HasField('message'): response_parts.append(get_message_text(event.message)) elif event.HasField('status_update'): - if event.status_update.task_id: + if not self._task_id and event.status_update.task_id: self._task_id = event.status_update.task_id if event.status_update.status.state in _TERMINAL_STATES: self._task_id = None diff --git a/tests/client/test_text_client.py b/tests/client/test_text_client.py index 1934e581c..380033a3c 100644 --- a/tests/client/test_text_client.py +++ b/tests/client/test_text_client.py @@ -55,9 +55,9 @@ async def create_stream(*args, **kwargs): resp0.task.id = 'task-1' yield resp0 - # Event 1: direct message + # Event 1: status update resp1 = StreamResponse() - resp1.message.parts.append(Part(text='Hello')) + resp1.status_update.status.message.parts.append(Part(text='Hello')) yield resp1 # Event 2: status update without message @@ -87,20 +87,17 @@ async def create_stream(*args, **kwargs): @pytest.mark.asyncio -async def test_send_text_message_custom_delimiter( +async def test_send_text_message( text_client: TextClient, mock_client: AsyncMock ) -> None: async def create_stream(*args, **kwargs): resp1 = StreamResponse() resp1.message.parts.append(Part(text='Hello')) yield resp1 - resp2 = StreamResponse() - resp2.artifact_update.artifact.parts.append(Part(text='World')) - yield resp2 mock_client.send_message.return_value = create_stream() - response = await text_client.send_text_message('Hi', delimiter='\n') - assert response == 'Hello\nWorld' + response = await text_client.send_text_message('Hi') + assert response == 'Hello' @pytest.mark.asyncio