Skip to content

feat: add async context manager support for CopilotClient and Copilot…#475

Open
Sumanth007 wants to merge 3 commits intogithub:mainfrom
Sumanth007:feat/async-context-manager
Open

feat: add async context manager support for CopilotClient and Copilot…#475
Sumanth007 wants to merge 3 commits intogithub:mainfrom
Sumanth007:feat/async-context-manager

Conversation

@Sumanth007
Copy link

@Sumanth007 Sumanth007 commented Feb 15, 2026

Session with automatic resource cleanup #341

This pull request introduces async context manager support for both CopilotClient and CopilotSession, enabling automatic resource cleanup and following Python best practices for resource management. The documentation is updated to recommend this pattern, and comprehensive tests ensure correct behavior and error handling. These changes make it easier and safer to use the SDK, especially in batch or evaluation scenarios.

Async context manager support and resource management:

  • Added async context manager methods (__aenter__ and __aexit__) to CopilotClient and CopilotSession for automatic startup, cleanup, and error handling, ensuring resources are always released—even if exceptions occur. [1] [2]
  • Updated the python/README.md to document and recommend the context manager usage pattern, including example code and benefits, and highlighted this feature in the capabilities list. [1] [2] [3]

Testing and validation:

  • Added new end-to-end tests in python/e2e/test_context_managers.py to verify correct context manager behavior, including automatic cleanup, error propagation, and nested context scenarios.
  • Introduced unit tests in python/test_client.py to confirm that context manager methods return the correct values and propagate exceptions as expected.

Internal improvements:

  • Imported logging and TracebackType in relevant files to support error logging and context manager implementation. [1] [2]

Copilot AI review requested due to automatic review settings February 15, 2026 16:00
@Sumanth007 Sumanth007 requested a review from a team as a code owner February 15, 2026 16:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds async context manager (async with) support to the Python SDK’s CopilotClient and CopilotSession so callers can get automatic startup/teardown, and updates Python docs + tests to encourage/verify the pattern.

Changes:

  • Implemented __aenter__ / __aexit__ on CopilotClient to auto-start() and cleanup via stop().
  • Implemented __aenter__ / __aexit__ on CopilotSession to auto-cleanup via destroy().
  • Added unit/E2E tests and updated python/README.md to recommend async with usage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
python/copilot/client.py Adds async context manager methods and cleanup logging for the client lifecycle.
python/copilot/session.py Adds async context manager methods for session lifecycle cleanup.
python/test_client.py Adds unit tests for context manager return values / exception propagation behavior.
python/e2e/test_context_managers.py Adds E2E coverage for client/session context manager behavior and cleanup semantics.
python/README.md Documents and recommends async with usage patterns for safer resource cleanup.

Comment on lines 78 to 86
client = CopilotClient({"cli_path": CLI_PATH})
await client.start()

try:
async with await client.create_session() as session:
assert session.session_id
# Send a message to verify session is working
await session.send({"prompt": "Hello!"})

Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: this test creates a standalone CopilotClient without the E2E proxy env/cwd setup, but then calls session.send(...). That will bypass the replay proxy and can fail depending on auth/network availability. Prefer using ctx.client (or pass env=ctx.get_env() and cwd=ctx.work_dir).

Copilot uses AI. Check for mistakes.
await self.stop()
except Exception as e:
# Log the error but don't raise - we want cleanup to always complete
logging.warning(f"Error during CopilotClient cleanup: {e}")
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup exception logging uses logging.warning(f"..."), which drops the traceback and eagerly formats the message. For better diagnostics, consider using logging.exception(...) or logging.warning(..., exc_info=True) (and pass the exception as an arg rather than f-string formatting).

Suggested change
logging.warning(f"Error during CopilotClient cleanup: {e}")
logging.warning(
"Error during CopilotClient cleanup: %s",
e,
exc_info=True,
)

Copilot uses AI. Check for mistakes.
Comment on lines 126 to 128
except Exception as e:
# Log the error but don't raise - we want cleanup to always complete
logging.warning(f"Error during CopilotSession cleanup: {e}")
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup exception logging uses logging.warning(f"..."), which omits the traceback and eagerly formats the message. Consider logging.exception(...) or logging.warning(..., exc_info=True) (and pass the exception as an argument) to make cleanup failures diagnosable.

Suggested change
except Exception as e:
# Log the error but don't raise - we want cleanup to always complete
logging.warning(f"Error during CopilotSession cleanup: {e}")
except Exception:
# Log the error but don't raise - we want cleanup to always complete
logging.warning("Error during CopilotSession cleanup", exc_info=True)

Copilot uses AI. Check for mistakes.
Comment on lines 250 to 255
try:
await self.stop()
except Exception as e:
# Log the error but don't raise - we want cleanup to always complete
logging.warning(f"Error during CopilotClient cleanup: {e}")
return False
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CopilotClient.__aexit__ calls stop() but ignores the returned StopError list. This means session-destroy failures during cleanup are silently dropped, contradicting the docstring claim that cleanup errors are logged. Consider capturing the return value from stop() and logging each StopError (or aggregating/raising in a controlled way) so cleanup failures are visible to users of the context manager.

Copilot uses AI. Check for mistakes.
Comment on lines 124 to 129
try:
await self.destroy()
except Exception as e:
# Log the error but don't raise - we want cleanup to always complete
logging.warning(f"Error during CopilotSession cleanup: {e}")
return False
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CopilotSession.__aexit__ destroys the session but the owning CopilotClient still keeps the session in its internal _sessions map. When the client later stops (including via CopilotClient.__aexit__), it will attempt to destroy this already-destroyed session again, producing spurious cleanup errors (and keeping references alive longer than needed). Consider making session destruction unregister from the client (e.g., via an owner reference/callback) or making destroy() idempotent so repeated cleanup does not error.

Copilot uses AI. Check for mistakes.
Comment on lines 15 to 19
async with CopilotClient({"cli_path": CLI_PATH}) as client:
assert client.get_state() == "connected"
# Verify we can use the client
pong = await client.ping("test")
assert pong.message == "pong: test"
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These E2E tests construct new CopilotClient instances without the replay-proxy environment (ctx.get_env() / COPILOT_API_URL) or isolated cwd. Because the tests call session.send(...), they will take the real network/auth path instead of the snapshot proxy and are likely to be flaky or fail in CI. Align with the other snapshot-based E2E tests by using the ctx fixture (e.g., ctx.client) or by passing env=ctx.get_env() and cwd=ctx.work_dir when creating the client.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment on lines 12 to 72
class TestCopilotClientContextManager:
async def test_should_auto_start_and_cleanup_with_context_manager(self, ctx):
"""Test that CopilotClient context manager auto-starts and cleans up."""
async with ctx.client as client:
assert client.get_state() == "connected"
# Verify we can use the client
pong = await client.ping("test")
assert pong.message == "pong: test"

# After exiting context, client should be disconnected
assert client.get_state() == "disconnected"

async def test_should_create_session_in_context(self, ctx):
"""Test creating and using a session within client context."""
async with ctx.client as client:
session = await client.create_session({"model": "fake-test-model"})
assert session.session_id

# Verify session is usable
messages = await session.get_messages()
assert len(messages) > 0
assert messages[0].type.value == "session.start"

# After exiting context, verify cleanup happened
assert client.get_state() == "disconnected"

async def test_should_cleanup_multiple_sessions(self, ctx):
"""Test that all sessions are cleaned up when client context exits."""
async with ctx.client as client:
session1 = await client.create_session()
session2 = await client.create_session()
session3 = await client.create_session()

assert session1.session_id
assert session2.session_id
assert session3.session_id

# All sessions should be cleaned up
assert client.get_state() == "disconnected"

async def test_should_propagate_exceptions(self, ctx):
"""Test that exceptions within context are propagated."""
with pytest.raises(ValueError, match="test error"):
async with ctx.client as client:
assert client.get_state() == "connected"
raise ValueError("test error")

# Client should still be cleaned up even after exception
assert client.get_state() == "disconnected"

async def test_should_handle_cleanup_errors_gracefully(self, ctx):
"""Test that cleanup errors don't prevent context from exiting."""
async with ctx.client as client:
await client.create_session()

# Kill the process to force cleanup to fail
if client._process:
client._process.kill()

# Context should still exit successfully despite cleanup errors
assert client.get_state() == "disconnected"
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests in TestCopilotClientContextManager use the shared fixture client (ctx.client) within a context manager that calls stop on exit. This creates a pattern where each test restarts the shared fixture's client. While this may work due to start/stop being somewhat idempotent, it's an anti-pattern that mixes fixture-managed lifecycle with context-manager-managed lifecycle. Consider creating fresh CopilotClient instances for each test instead of reusing ctx.client, passing ctx.cli_path and ctx.get_env() to properly configure them with the test harness proxy.

Copilot uses AI. Check for mistakes.
python/README.md Outdated
# Client automatically starts on enter and cleans up on exit
async with CopilotClient() as client:
# Create a session with automatic cleanup
async with await client.create_session({"model": "gpt-5"}) as session:
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example uses "gpt-5" which is not a real model as of January 2025. Consider using "gpt-4" to match other examples in the file (lines 432, 447), or use "gpt-4o" for a more current example.

Copilot uses AI. Check for mistakes.
Comment on lines 539 to 546
if self._destroyed:
return

try:
await self._client.request("session.destroy", {"sessionId": self.session_id})
finally:
# Mark as destroyed and clear handlers even if the request fails
self._destroyed = True
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check and set of self._destroyed is not atomic, which could lead to a race condition if multiple tasks call destroy() concurrently. While the current implementation is mostly safe due to the try/finally block, consider using a lock or making this atomic to prevent the RPC request from being sent multiple times. For example: use self._event_handlers_lock to protect both the flag check and the RPC call, or use an asyncio.Lock.

Suggested change
if self._destroyed:
return
try:
await self._client.request("session.destroy", {"sessionId": self.session_id})
finally:
# Mark as destroyed and clear handlers even if the request fails
self._destroyed = True
# Ensure that the check and update of _destroyed are atomic so that
# only the first caller proceeds to send the destroy RPC.
with self._event_handlers_lock:
if self._destroyed:
return
self._destroyed = True
try:
await self._client.request("session.destroy", {"sessionId": self.session_id})
finally:
# Clear handlers even if the request fails

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +11 to +13
pytestmark = pytest.mark.asyncio(loop_scope="module")


Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The E2E tests require snapshot files in the test/snapshots/context_managers/ directory to function properly. The test harness expects YAML snapshot files for each test to replay CAPI responses. The snapshot directory test/snapshots/context_managers/ and corresponding YAML files (e.g., should_auto_start_and_cleanup_with_context_manager.yaml, should_create_session_in_context.yaml, etc.) are missing.

The tests will fail without these snapshot files because the replaying proxy uses them to mock API responses. You'll need to either:

  1. Create the snapshot directory and YAML files with appropriate test data
  2. Generate the snapshots by running the tests in record mode first
  3. Copy and adapt existing snapshot files from similar tests
Suggested change
pytestmark = pytest.mark.asyncio(loop_scope="module")
SNAPSHOT_DIR = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"test",
"snapshots",
"context_managers",
)
pytestmark = [
pytest.mark.asyncio(loop_scope="module"),
pytest.mark.skipif(
not os.path.isdir(SNAPSHOT_DIR),
reason="context_managers snapshots are missing; run E2E in record mode to generate them",
),
]

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant