Skip to content

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

Description

@giles17

Summary

RawClaudeAgent keeps a single mutable ClaudeSDKClient on the agent instance and reuses it across distinct AgentSession objects when both sessions have not yet been bound to a provider conversation. As a result, two independent fresh sessions run against the same shared agent instance end up sharing one provider conversation, so the second session continues the first session's conversation instead of starting its own.

This is a session-continuity/isolation bug in the client lifecycle logic. It shows up whenever one long-lived ClaudeAgent instance is shared across multiple logical sessions (for example, a single hosted agent serving multiple sessions).

Affected component

  • Package: agent-framework-claude
  • File: python/packages/claude/agent_framework_claude/_agent.py
  • Method: RawClaudeAgent._ensure_session()

Root cause

_get_stream() resolves the provider continuation id from the session and passes it to _ensure_session():

# python/packages/claude/agent_framework_claude/_agent.py
session = session or self.create_session()
await self._ensure_session(self._get_chat_conversation_id(session))

For a fresh session, service_session_id is None, so _get_chat_conversation_id() returns None.

The reuse decision in _ensure_session() is:

needs_new_client = (
    not self._started or self._client is None or (session_id and session_id != self._current_session_id)
)

Walking two independent fresh sessions against the same agent instance:

  1. Session A (service_session_id=None) → not self._started is True → a new client is created, _current_session_id is set to None. The resulting provider session id is written back onto session A only.
  2. Session B (service_session_id=None) → _started is now True, _client is not None, and (None and ...) is falsey → needs_new_client is Falsethe existing client (still attached to session A's provider conversation) is reused. Session B therefore continues session A's conversation.

The _current_session_id field also only ever tracks the requested id (None for a fresh session), never the provider session id that the client actually became bound to, which is what allows the falsey comparison to collapse two distinct sessions onto one client.

Expected behavior

Two distinct fresh AgentSession objects run against the same agent instance must each get their own provider conversation. A session should only continue an existing provider conversation when it explicitly carries the matching continuation id (service_session_id).

Actual behavior

The second fresh session reuses the first session's still-active client and continues the first session's conversation, so conversation state leaks between independent sessions.

Proposed fix

Treat a fresh/unbound (None) continuation id as always requiring a new client, since a not-yet-bound session must never inherit an existing provider conversation:

needs_new_client = (
    not self._started
    or self._client is None
    or session_id is None                      # fresh/unbound session -> never reuse
    or session_id != self._current_session_id  # different explicit continuation id
)

This preserves legitimate continuity: 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.

As defense in depth, guard the client selection/creation in _ensure_session() with an asyncio.Lock so concurrent runs cannot race between the check and the client assignment.

Tests

  • Assert that two distinct fresh AgentSession objects on one cached agent produce two ClaudeSDKClient instantiations / distinct provider conversations.
  • Keep coverage for explicit continuation-id resume and for sequential and concurrent runs.
  • Update the existing test_ensure_session_reuses_for_same_session expectation, which currently encodes reuse for the None case.

Metadata

Metadata

Assignees

Labels

bugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)pythonUsage: [Issues, PRs], Target: Python

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions