Skip to content

Python: Fix ClaudeAgent reusing one SDK client across distinct fresh sessions - #7404

Draft
giles17 wants to merge 2 commits into
microsoft:mainfrom
giles17:fix-claude-session-isolation
Draft

Python: Fix ClaudeAgent reusing one SDK client across distinct fresh sessions#7404
giles17 wants to merge 2 commits into
microsoft:mainfrom
giles17:fix-claude-session-isolation

Conversation

@giles17

@giles17 giles17 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

RawClaudeAgent keeps a single mutable ClaudeSDKClient on the agent instance. A
ClaudeSDKClient is stateful and represents one provider conversation, so deciding
whether to reuse it is an isolation decision, not just a connection optimization.

When one long-lived ClaudeAgent instance is shared across multiple logical sessions
(for example, a single hosted agent serving many sessions), the client was reused across
distinct fresh AgentSession objects. A fresh session resolves its provider continuation
id to None, and the previous reuse check treated a None id as "keep the current
client". As a result, two independent fresh sessions ran against the same provider
conversation, and the second session continued the first session's conversation instead of
starting its own. This is a session-continuity/isolation bug in the client lifecycle logic.

Description & Review Guide

  • What are the major changes?

    • RawClaudeAgent._ensure_session() now treats a fresh/unbound session (session_id is None) as always requiring a new client, so an unbound session never inherits an
      existing client's provider conversation. The reuse decision is otherwise unchanged: a
      started client is reused only when an explicit continuation id matches the currently
      connected one.
    • Client selection/creation is guarded with an asyncio.Lock so concurrent runs cannot
      race between the reuse check and the client assignment.
    • Added regression tests: two fresh sessions on one agent produce two distinct clients,
      and an explicit continuation id still resumes the existing client.
  • What is the impact of these changes?

    • Distinct fresh sessions on a shared agent instance each get their own provider
      conversation, so conversation state no longer leaks between independent sessions.
    • Legitimate continuity is preserved: once a session runs, its service_session_id is
      written back, so subsequent runs pass a real (truthy) id and correctly resume the same
      conversation via resume=. Only genuinely fresh sessions get an isolated new client.
    • Not a breaking change to the public API.
  • What do you want reviewers to focus on?

    • The reuse condition in _ensure_session() and that resume-by-continuation-id continuity
      is retained for sessions that have already been bound to a provider conversation.

Related Issue

Fixes #7403

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…sessions

RawClaudeAgent kept a single mutable ClaudeSDKClient on the agent instance
and reused it across distinct fresh AgentSession objects, because a fresh
session passes session_id=None and the old reuse check treated that as
"keep the current client". Two independent fresh sessions on one shared
agent instance therefore shared a single provider conversation, so the
second session continued the first session's conversation.

Treat a fresh (None) continuation id as always requiring a new client, so
an unbound session never inherits an existing provider conversation.
Legitimate continuity is preserved: once a session runs, its
service_session_id is written back, so later runs pass a real id and resume
correctly. Guard client selection/creation with an asyncio.Lock so
concurrent runs cannot race between the check and the client assignment.

Add regression tests asserting two fresh sessions produce two clients and
that an explicit continuation id still resumes the existing client.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af
Copilot AI review requested due to automatic review settings July 29, 2026 18:26
@giles17
giles17 temporarily deployed to github-app-auth July 29, 2026 18:26 — with GitHub Actions Inactive
@giles17
giles17 temporarily deployed to github-app-auth July 29, 2026 18:26 — with GitHub Actions Inactive
@giles17
giles17 temporarily deployed to github-app-auth July 29, 2026 18:26 — with GitHub Actions Inactive
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/claude/agent_framework_claude
   _agent.py3353290%416–417, 421, 434–439, 449–451, 453, 487–488, 495–496, 516, 520, 522, 526, 535, 581, 584, 624, 629–630, 705, 841–844
TOTAL45845447890% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9461 34 💤 0 ❌ 0 🔥 2m 30s ⏱️

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Not ready to approve

The new lock does not prevent a concurrent run from disconnecting/replacing the SDK client while another stream is actively using it, and one added test currently validates a scenario via private state mutation that production code never performs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR fixes a session-isolation bug in the Python Claude agent where a single stateful ClaudeSDKClient instance could be incorrectly reused across distinct fresh AgentSessions, causing provider conversation state to leak between sessions (notably in long-lived/hosted agent scenarios).

Changes:

  • Update RawClaudeAgent._ensure_session() to treat session_id is None (fresh/unbound session) as always requiring a new client, preventing accidental cross-session reuse.
  • Add an asyncio.Lock to serialize client selection/creation and reduce races during client replacement.
  • Add regression tests covering “fresh sessions must not share a client” and “explicit continuation id reuse” scenarios.
File summaries
File Description
python/packages/claude/agent_framework_claude/_agent.py Adjusts client lifecycle rules for fresh vs. resumed sessions and adds a lock around client recreation logic.
python/packages/claude/tests/test_claude_agent.py Adds tests to prevent regressions around fresh-session isolation and continuation-id behavior.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Low

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread python/packages/claude/agent_framework_claude/_agent.py Outdated
Comment thread python/packages/claude/tests/test_claude_agent.py Outdated
Replace the single mutable ClaudeSDKClient stored on the agent with a
per-run client. Because a ClaudeSDKClient represents exactly one provider
conversation, sharing one across distinct sessions collapsed them onto the
same conversation and, for concurrent runs, let a fresh session disconnect
a client another run was still streaming from.

_acquire_client now returns a per-run client (owned) that resumes the
framework session's provider conversation when one exists, and _get_stream
releases it in a finally once the run completes. An injected client is
reused verbatim and left to the caller. The streaming loop moves into
_stream_run so the client is a local per-run value rather than shared agent
state, which keeps distinct sessions isolated even under concurrency.

Continuity is preserved: a session's service_session_id is written back
after each run and forwarded as the resume id on subsequent runs. Replace
the client-lifecycle tests with per-run ownership and end-to-end isolation
tests (two fresh sessions get two separate clients, each disconnected).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 598a9fe1-28c5-4db1-88fd-e14acd9340af

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Not ready to approve

The implementation materially diverges from the PR description (removes _ensure_session/reuse path and adds no lock), and the injected-client connect path needs synchronization to avoid concurrent connect() races.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Comments suppressed due to low confidence (4)

python/packages/claude/agent_framework_claude/_agent.py:438

  • start() checks _started without synchronization; if concurrent tasks call start() (or start() overlaps with _acquire_client()), the injected client can be connected multiple times. Wrap the _started check + connect() in the instance lock.
        if self._client is not None and not self._owns_client and not self._started:
            try:
                await self._client.connect()
                self._started = True
            except Exception as ex:

python/packages/claude/agent_framework_claude/_agent.py:487

  • _acquire_client() has the same unsynchronized _started/connect() path for injected clients; concurrent calls can race and attempt multiple connects. Use the same instance lock here to ensure connect() is awaited at most once.
            if not self._started:
                try:
                    await self._client.connect()
                    self._started = True
                except Exception as ex:

python/packages/claude/agent_framework_claude/_agent.py:459

  • The PR description/linked issue describe adjusting _ensure_session() to only reuse a started client when a non-None continuation id matches, plus guarding selection with an asyncio.Lock. The implementation instead removes _ensure_session() and always creates a fresh SDK client per run (except for injected clients), and there is currently no lock at all. If the per-run-client redesign is intended, the PR description (and issue’s proposed fix narrative) should be updated; otherwise, the code likely needs to reintroduce reuse-by-continuation-id logic with locking as described.
    async def _acquire_client(self, resume_session_id: str | None = None) -> tuple[ClaudeSDKClient, bool]:
        """Acquire a Claude SDK client for a single run.

        A ``ClaudeSDKClient`` is stateful and represents exactly one provider
        conversation, so obtaining it is an isolation decision, not just a

python/packages/claude/tests/test_claude_agent.py:606

  • The injected-client reuse test is sequential, so it won’t catch the real race where two concurrent runs both attempt to connect the injected client. Consider making this test run the two _acquire_client() calls concurrently (e.g., via asyncio.gather) so it fails without the lock and prevents regressions.
        client_a, owns_a = await agent._acquire_client(None)  # type: ignore[reportPrivateUsage]
        client_b, owns_b = await agent._acquire_client("session-123")  # type: ignore[reportPrivateUsage]

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Low

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines 386 to 388
self._default_options = opts
self._started = False
self._current_session_id: str | None = None
self._structured_output: Any = None
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: ClaudeAgent reuses one SDK client across distinct fresh sessions, leaking conversation state

2 participants